From 772f69117d6660af326ced71271bd86b36f6d52a Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Sun, 24 Aug 2025 17:30:17 -0700 Subject: [PATCH 01/17] echo variables --- kernelboard/api/auth.py | 36 ++++------ kernelboard/api/submission.py | 89 ++++++++++++++++++++++++ kernelboard/lib/auth_utils.py | 115 ++++++++++++++++++++++++++++++++ kernelboard/lib/error.py | 46 +++++++++++++ kernelboard/lib/file_handler.py | 56 ++++++++++++++++ requirements.txt | 1 + 6 files changed, 321 insertions(+), 22 deletions(-) create mode 100644 kernelboard/api/submission.py create mode 100644 kernelboard/lib/auth_utils.py create mode 100644 kernelboard/lib/error.py create mode 100644 kernelboard/lib/file_handler.py diff --git a/kernelboard/api/auth.py b/kernelboard/api/auth.py index ec4c084b..260b7fc0 100644 --- a/kernelboard/api/auth.py +++ b/kernelboard/api/auth.py @@ -1,7 +1,9 @@ from __future__ import annotations +from ast import Tuple import os import secrets +from typing import Any, Optional from urllib.parse import urlencode import requests @@ -16,6 +18,8 @@ url_for, ) from flask_login import UserMixin, current_user, login_user, logout_user +from kernelboard.lib.auth_utils import ensure_user_info_with_token, get_user_info_from_session + from kernelboard.lib.status_code import http_success auth_bp = Blueprint("auth", __name__) @@ -234,15 +238,20 @@ def callback(provider: str): data = me_res.json() or {} identity = provider_data["userinfo"]["identity"](data) + username = data.get("global_name") or data.get("username") or "unknown" # 4) Stash display-only info (safe for SPA header) - session["display_name"] = data.get("global_name") or data.get("username") + session["display_name"] = username session["avatar_url"] = _discord_avatar_url(identity, data.get("avatar")) + # ensure user exists and has web_token_id + # if not, update the user with the new token + ensure_user_info_with_token(identity, username) + # 5) Log in login_user(User(f"{provider}:{identity}")) - # 6) Clean up and redirect + # 6) Clean up and redirec next_url = session.pop("oauth2_next", None) session.pop("oauth2_state", None) return redirect(next_url or "/kb/") @@ -260,24 +269,7 @@ def logout(): @auth_bp.get("/me") def me(): - is_auth = not current_user.is_anonymous - user_id = current_user.get_id() if is_auth else None - - # Optional: split provider:id -> provider, identity - provider = identity = None - if user_id and ":" in user_id: - provider, identity = user_id.split(":", 1) - res = { - "authenticated": is_auth, - "user": { - "id": user_id, - "provider": provider, - "identity": identity, - "display_name": session.get("display_name") if is_auth else None, - "avatar_url": session.get("avatar_url") if is_auth else None, - }, - # Handy URLs for the frontend: - "login_url": url_for("api.auth.auth", provider="discord"), - "logout_url": url_for("api.auth.logout"), - } + res = get_user_info_from_session() + res.update("login_url", url_for("api.auth.auth", provider="discord")) + res.update("logout_url", url_for("api.auth.logout")) return http_success(res) diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py new file mode 100644 index 00000000..3d73044c --- /dev/null +++ b/kernelboard/api/submission.py @@ -0,0 +1,89 @@ +import http +from flask import Blueprint, request, jsonify, current_app +import requests +from kernelboard.lib.auth_utils import get_id_and_username_from_session, get_user_token, is_auth +from kernelboard.lib.error import ValidationError, validate_required_fields +from kernelboard.lib.file_handler import get_submission_file_info +from kernelboard.lib.status_code import http_error +import logging + + +logger = logging.getLogger(__name__) +sub_bp = Blueprint("submisison_api", __name__, url_prefix="/submission") + + +REQUIRED_SUBMISSION_REQUEST_FIELDS = ["file","leaderboard_","gpu_type", "submission_mode"] +SUBMISSION_API_BASE = "https://your.external.api/submission" +WEB_AUTH_HEADER = "X-Web-Auth-Id" +MAX_CONTENT_LENGTH = 20 * 1024 * 1024 # 20MB max file size + + +@sub_bp.before_app_request +def _limit_size(): + current_app.config.setdefault("MAX_CONTENT_LENGTH", MAX_CONTENT_LENGTH) + +@sub_bp.route("/submission", methods=["POST"]) +def submission(): + # make sure user is logged in + if not is_auth(): + return http_error( + message="cannnot get user id, please log in first, if this is unexpected, please contact the gpumode administrator", + code=10000 + http.HTTPStatus.UNAUTHORIZED.value, + status_code=http.HTTPStatus.UNAUTHORIZED, + ) + user_id, username = get_id_and_username_from_session() + + web_token = get_user_token(user_id) + if not web_token: + return http_error( + message="cannot find user info from db for user %s, if this is a bug, please contact the gpumode administrator" % username, + code=10000 + http.HTTPStatus.INTERNAL_SERVER_ERROR.value, + status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + req = request.form.to_dict() + try: + validate_required_fields(req, REQUIRED_SUBMISSION_REQUEST_FIELDS) + filename, mime, f = get_submission_file_info(request) + except ValidationError as e: + return http_error( + message=e.message, + code=e.code, + status_code=e.status, + **e.extras, + ) + except Exception as e: + return http_error( + message=str(e), + code=10000 + http.HTTPStatus.INTERNAL_SERVER_ERROR.value, + status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + # form post request to external api + gpu_type = request.form.get("gpu_type") + submission_mode = request.form.get("submission_mode") + leaderboard_name = request.form.get("leaderboard_name") + + url = f"{SUBMISSION_API_BASE}/{leaderboard_name}/{gpu_type}/{submission_mode}" + + files = { + # requests expects (filename, fileobj, content_type) + "file": (filename, f.stream, mime), + } + headers = { + WEB_AUTH_HEADER: web_token, + } + + try: + resp = requests.post(url, headers=headers, files=files, timeout=180) + except requests.RequestException as e: + return jsonify({"error": f"forward failed: {e}"}), 502 + + # Pass-through response + try: + data = resp.json() + return jsonify(data), resp.status_code + except ValueError: + return resp.text, resp.status_code, { + "Content-Type": resp.headers.get("Content-Type", "text/plain") + } diff --git a/kernelboard/lib/auth_utils.py b/kernelboard/lib/auth_utils.py new file mode 100644 index 00000000..88789985 --- /dev/null +++ b/kernelboard/lib/auth_utils.py @@ -0,0 +1,115 @@ + +from ast import Tuple +import secrets +from typing import Any, Optional + +from flask import session +from flask_login import current_user + +from kernelboard.lib.db import get_db_connection + + +def get_provider_and_identity(user_id: Optional[str])-> Any: + provider = identity = None + if user_id and ":" in user_id: + provider, identity = user_id.split(":", 1) + return { + "provider": provider, + "identity": identity, + } + +def get_user_info_from_session() -> Any: + is_auth = not current_user.is_anonymous + user_id = current_user.get_id() if is_auth else None + d = get_provider_and_identity(user_id) + provider = d["provider"] + identity = d["identity"] + res = { + "authenticated": is_auth, + "user": { + "id": user_id, + "provider": provider, + "identity": identity, + "display_name": session.get("display_name") if is_auth else None, + "avatar_url": session.get("avatar_url") if is_auth else None, + }, + } + return res + +def get_id_and_username_from_session(): + """ + Get identity, display_name from session. + Returns: + (identity, display_name, is_auth) + - identity: str or None + - display_name: str or None + """ + info = get_user_info_from_session() + identity = info["user"]["identity"] + display_name = info["user"]["display_name"] + return identity, display_name + +def is_auth() -> bool: + return not current_user.is_anonymous + +def ensure_user_info_with_token(user_id: int, user_name: str) -> Optional[Any]: + """ + Idempotent behavior: + - If user does not exist -> INSERT with new token and return the row. + - If user exists and web_token_id IS NULL -> UPDATE to set token and return the row. + - If user exists and web_token_id IS NOT NULL -> do not overwrite; just SELECT and return existing row. + """ + new_token = secrets.token_hex(16) + conn = get_db_connection() + try: + with conn: # automatically commit on success / rollback on error + with conn.cursor() as cur: + # Attempt "insert or update only if web_token_id is NULL" + cur.execute( + """ + INSERT INTO leaderboard.user_info (id, user_name, web_token_id) + VALUES (%s, %s, %s) + ON CONFLICT (id) DO UPDATE + SET web_token_id = EXCLUDED.web_token_id + WHERE leaderboard.user_info.web_token_id IS NULL + RETURNING id, user_name, web_token_id + """, + (user_id, user_name, new_token), + ) + row = cur.fetchone() + + # row exists if inserted new row OR updated an existing row with NULL token + if row: + return row + + # if no upsert was done, fetch the existing row and return it + cur.execute( + """ + SELECT id, user_name, web_token_id + FROM leaderboard.user_info + WHERE id = %s + """, + (user_id,), + ) + return cur.fetchone() + finally: + conn.close() + +def get_user_token(user_id: int) -> Optional[str]: + conn = get_db_connection() + try: + with conn: # auto commit / rollback + with conn.cursor() as cur: + cur.execute( + """ + SELECT web_token_id + FROM leaderboard.user_info + WHERE id = %s + """, + (user_id,), + ) + row = cur.fetchone() + # row will be a tuple like (token,) or None + return row[0] if row else None + finally: + conn.close() diff --git a/kernelboard/lib/error.py b/kernelboard/lib/error.py new file mode 100644 index 00000000..8742b2b4 --- /dev/null +++ b/kernelboard/lib/error.py @@ -0,0 +1,46 @@ +import http +from typing import List + +class ValidationError(Exception): + def __init__(self, message: str, + status: http.HTTPStatus = http.HTTPStatus.BAD_REQUEST, + code: int | None = None, **extras): + super().__init__(message) + self.message = message + self.status = status + self.code = code or (10000 + status.value) + self.extras = extras + +class MissingRequiredFieldError(ValidationError): + def __init__(self, message="missing required submission python file"): + super().__init__(message, http.HTTPStatus.BAD_REQUEST, 100400) + +class InvalidPythonExtensionError(ValidationError): + def __init__(self, message="invalid file extension, only single python file with .py allowed"): + super().__init__(message, http.HTTPStatus.BAD_REQUEST, 100401) + +class InvalidMimeError(ValidationError): + def __init__(self, mime: str | None = None, message: str | None = None): + msg = message or (f"invalid MIME type: {mime}, expected Python source") + super().__init__(msg, http.HTTPStatus.UNSUPPORTED_MEDIA_TYPE, 100415, mime=mime) + +class InvalidSyntaxError(ValidationError): + def __init__(self, detail: str): + super().__init__(f"invalid Python syntax: {detail}", + http.HTTPStatus.UNPROCESSABLE_ENTITY, 100422) + + +def validate_required_fields(data: dict, field_names: List[str] ): + """ + Validate that the request data contains the required fields. + Args: + data: dictionary (could be request.form, request.json, etc.) + Raises: + MissingRequiredFieldError if any field is missing + """ + for field in field_names: + value = data.get(field) + if not value: + raise MissingRequiredFieldError( + f"Missing required field: {field.lower()}" + ) diff --git a/kernelboard/lib/file_handler.py b/kernelboard/lib/file_handler.py new file mode 100644 index 00000000..a41f8d6e --- /dev/null +++ b/kernelboard/lib/file_handler.py @@ -0,0 +1,56 @@ +import ast, re, magic +from typing import List +from flask import current_app +from werkzeug.utils import secure_filename +from errors import ( + ValidationError, MissingRequiredFieldError, InvalidPythonExtensionError, + InvalidMimeError, InvalidSyntaxError +) +from kernelboard.lib.error import validate_required_fields + +ALLOWED_EXTS = {".py"} +ALLOWED_PYTHON_MIMES = {"text/x-python", "text/x-script.python", "text/plain"} +MAX_CONTENT_LENGTH = 1_000_000 # 1 MB cap for file content you parse +_TEXT_CTRL_RE = re.compile(rb"[\x00-\x08\x0B\x0C\x0E-\x1F]") + +def get_submission_file_info(request): + if "file" not in request.files: + raise MissingRequiredFieldError() + + f = request.files["file"] + filename = secure_filename(f.filename or "") + if not filename: + raise MissingRequiredFieldError( + "missing required submission python file, if this is unexpected, please contact the gpumode administrator" + ) + + ext = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if ext not in ALLOWED_EXTS: + raise InvalidPythonExtensionError() + + # MIME sniff (peek) + sample = f.stream.read(2048) + f.stream.seek(0) + mime = magic.from_buffer(sample, mime=True) or "" + if mime not in ALLOWED_PYTHON_MIMES: + raise InvalidMimeError(mime=mime) + + # Full bounded read for validation + raw = f.stream.read(MAX_CONTENT_LENGTH + 1) + f.stream.seek(0) + if not raw: + raise InvalidSyntaxError("file is empty") + if len(raw) > MAX_CONTENT_LENGTH: + raise InvalidSyntaxError(f"file too large (> {MAX_CONTENT_LENGTH} bytes)") + if _TEXT_CTRL_RE.search(raw) or b"\x00" in raw: + raise InvalidMimeError(message="binary content detected; not Python text") + + try: + text = raw.decode("utf-8", errors="strict") + ast.parse(text, filename=filename, mode="exec") + except UnicodeDecodeError: + raise InvalidSyntaxError("file is not valid UTF-8 text") + except SyntaxError as e: + raise InvalidSyntaxError(f"{e.msg} at line {e.lineno}") + + return filename, mime, f diff --git a/requirements.txt b/requirements.txt index 7220ad3c..89916a38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,3 +22,4 @@ requests>=2.32.3,<3.0.0 urllib3>=2.4.0,<3.0.0 Werkzeug>=3.1.3,<3.2.0 pyyaml>=6.0.1 +python-magic>=0.4.27 From 0a905744414bb5ef63fbd7dc3d9fc9232515411a Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Mon, 25 Aug 2025 14:01:04 -0700 Subject: [PATCH 02/17] echo variables --- frontend/src/api/api.ts | 53 ++++ frontend/src/lib/hooks/useApi.ts | 1 - frontend/src/lib/types/mode.ts | 10 + .../src/pages/leaderboard/Leaderboard.tsx | 211 ++++++++++++---- .../components/LeaderboardSubmitDialog.tsx | 236 ++++++++++++++++++ .../components/ListSubmissionsSidePanel.tsx | 202 +++++++++++++++ kernelboard/api/__init__.py | 5 +- kernelboard/api/auth.py | 6 +- kernelboard/api/submission.py | 173 +++++++++++-- kernelboard/lib/auth_utils.py | 20 +- kernelboard/lib/file_handler.py | 59 +++-- kernelboard/lib/time.py | 7 + requirements.txt | 1 - tests/conftest.py | 10 +- tests/data.sql | 3 +- 15 files changed, 903 insertions(+), 94 deletions(-) create mode 100644 frontend/src/lib/types/mode.ts create mode 100644 frontend/src/pages/leaderboard/components/LeaderboardSubmitDialog.tsx create mode 100644 frontend/src/pages/leaderboard/components/ListSubmissionsSidePanel.tsx diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 0f6088fc..064b708f 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -73,3 +73,56 @@ export async function logout(): Promise { const r = await res.json(); return r.data; } + +export async function submitFile(form: FormData) { + for (const [k, v] of form.entries()) { + if (v instanceof File) { + console.log(`${k}: File(name=${v.name}, size=${v.size}, type=${v.type})`); + } else { + console.log(`${k}:`, v); + } + } + const resp = await fetch("/api/submission", { + method: "POST", + body: form, + }); + + const text = await resp.text(); + let data: any; + try { + data = JSON.parse(text); + } catch { + data = { raw: text }; + } + + if (!resp.ok) { + const msg = data?.detail || data?.message || "Submission failed"; + throw new Error(msg); + } + + return data; // e.g. { submission_id, message, ... } +} + +export async function fetchUserSubmissions( + leaderboardId: number | string, + userId: number | string, + page: number = 1, + pageSize: number = 10, +): Promise { + const offset = (page - 1) * pageSize; + const res = await fetch( + `/api/submissions?leaderboard_id=${leaderboardId}&offset=${offset}&limit=${pageSize}`, + ); + if (!res.ok) { + let message = "Unknown error"; + try { + const json = await res.json(); + message = json?.detail || json?.message || message; + } catch { + /* ignore */ + } + throw new APIError(`Failed to fetch submissions: ${message}`, res.status); + } + const r = await res.json(); + return r.data; // 直接返回 data 对象 { items, total, page, ... } +} diff --git a/frontend/src/lib/hooks/useApi.ts b/frontend/src/lib/hooks/useApi.ts index 4567edd3..6a767eaf 100644 --- a/frontend/src/lib/hooks/useApi.ts +++ b/frontend/src/lib/hooks/useApi.ts @@ -1,5 +1,4 @@ import { useCallback, useState } from "react"; -import { APIError } from "../../api/api"; import { useNavigate } from "react-router-dom"; type Fetcher = (...args: Args) => Promise; diff --git a/frontend/src/lib/types/mode.ts b/frontend/src/lib/types/mode.ts new file mode 100644 index 00000000..f484c477 --- /dev/null +++ b/frontend/src/lib/types/mode.ts @@ -0,0 +1,10 @@ +export const SubmissionMode = { + TEST: "test", + BENCHMARK: "benchmark", + PROFILE: "profile", + LEADERBOARD: "leaderboard", + PRIVATE: "private", +} as const; + +export type SubmissionMode = + (typeof SubmissionMode)[keyof typeof SubmissionMode]; diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index 7fd69fd6..d38fb872 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -1,67 +1,120 @@ -import { Box, Card, CardContent, styled, Typography } from "@mui/material"; +import { + Box, + Button, + Card, + CardContent, + Stack, + styled, + Tab, + Tabs, + Typography, +} from "@mui/material"; import Grid from "@mui/material/Grid"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { fetchLeaderBoard } from "../../api/api"; import { fetcherApiCallback } from "../../lib/hooks/useApi"; import { toDateUtc } from "../../lib/date/utils"; import RankingsList from "./components/RankingLists"; import CodeBlock from "../../components/codeblock/CodeBlock"; import { ErrorAlert } from "../../components/alert/ErrorAlert"; -import { useParams } from "react-router-dom"; +import { useParams, useSearchParams } from "react-router-dom"; import Loading from "../../components/common/loading"; import { ConstrainedContainer } from "../../components/app-layout/ConstrainedContainer"; - -export const CardTitle = styled(Typography)(({ theme }) => ({ +import { SubmissionMode } from "../../lib/types/mode"; +import { useAuthStore } from "../../lib/store/authStore"; +import ListSubmissionSidePanel from "./components/ListSubmissionsSidePanel"; +import LeaderboardSubmitDialog from "./components/LeaderboardSubmitDialog"; +export const CardTitle = styled(Typography)(() => ({ fontSize: "1.5rem", fontWeight: "bold", })); +const TAB_KEYS = ["rankings", "reference", "submission"] as const; +type TabKey = (typeof TAB_KEYS)[number]; + +// Tab accessibility props +function a11yProps(index: number) { + return { + id: `leaderboard-tab-${index}`, + "aria-controls": `leaderboard-tabpanel-${index}`, + }; +} + +// Panel wrapper for tab content +function TabPanel(props: { + children?: React.ReactNode; + value: string; + index: number; +}) { + const { children, value, index, ...other } = props; + return ( + + ); +} + export default function Leaderboard() { const { id } = useParams<{ id: string }>(); const { data, loading, error, errorStatus, call } = fetcherApiCallback(fetchLeaderBoard); + const me = useAuthStore((s) => s.me); + const isAuthed = !!(me && me.authenticated); + const userId = me?.user?.identity ?? null; + + const [openSubs, setOpenSubs] = useState(false); + + // Sync tab state with query parameter + const [searchParams, setSearchParams] = useSearchParams(); + const initialTabFromUrl = ((): TabKey => { + const t = (searchParams.get("tab") || "").toLowerCase(); + return (TAB_KEYS as readonly string[]).includes(t) + ? (t as TabKey) + : "rankings"; + })(); + const [tab, setTab] = useState(initialTabFromUrl); useEffect(() => { - if (!id) { - return; + const current = searchParams.get("tab"); + if (current !== tab) { + const next = new URLSearchParams(searchParams); + next.set("tab", tab); + setSearchParams(next, { replace: true }); } - call(id); + }, [tab]); + + // Fetch leaderboard data + useEffect(() => { + if (id) call(id); }, [id]); - if (loading) { - return ; - } - - // handles specific error - if (error) { - return ; - } - - const descriptionText = (text: string) => { - return ( - - {text} - - ); - }; + if (loading) return ; + if (error) return ; + + const descriptionText = (text: string) => ( + + {text} + + ); + + const toDeadlineUTC = (raw: string) => `ended (${toDateUtc(raw)}) UTC`; - const toDeadlineUTC = (raw: string) => { - const formatted = toDateUtc(raw); - return `ended (${formatted}) UTC`; - }; const info_items = [ { title: "Deadline", content: {toDeadlineUTC(data.deadline)} }, { title: "Language", content: {data.lang} }, - { - title: "GPU types", - content: {data.gpu_types.join(", ")}, - }, + { title: "GPU types", content: {data.gpu_types.join(", ")} }, ]; - return (

{data.name}

+ {/* Header info cards shown above tabs */} {info_items.map((info, idx) => ( @@ -82,17 +135,89 @@ export default function Leaderboard() { - - - Reference Implementation - - - - - - - + + {/* Tab navigation */} + + setTab(v)} + aria-label="Leaderboard Tabs" + variant="scrollable" + scrollButtons + allowScrollButtonsMobile + > + + + + + + {/* Ranking Tab */} + + + {data.rankings.length > 0 ? ( + + ) : ( + + + No Submission Yet + + + Be the first to submit a solution for this challenge! + + + )} + + + + {/* Reference Implementation Tab */} + + + + Reference Implementation + + + + + + + + {/* Submission Tab */} + + {!isAuthed ? ( +
please login to submit
+ ) : ( + <> + + + Submission + + + + + + + + + + + + )} +
); diff --git a/frontend/src/pages/leaderboard/components/LeaderboardSubmitDialog.tsx b/frontend/src/pages/leaderboard/components/LeaderboardSubmitDialog.tsx new file mode 100644 index 00000000..6ddf2b91 --- /dev/null +++ b/frontend/src/pages/leaderboard/components/LeaderboardSubmitDialog.tsx @@ -0,0 +1,236 @@ +import React, { useMemo, useRef, useState } from "react"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContentText from "@mui/material/DialogContentText"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import Alert from "@mui/material/Alert"; +import CircularProgress from "@mui/material/CircularProgress"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import { submitFile } from "../../../api/api"; + +/** + * Subcomponent: LeaderboardSubmitDialog (MUI concise version) + * Parent provides only: leaderboardId, leaderboardName, gpuTypes, modes + */ +export default function LeaderboardSubmitDialog({ + leaderboardId, + leaderboardName, + gpuTypes, + modes, +}: { + leaderboardId: string; + leaderboardName: string; + gpuTypes: string[]; + modes: string[]; +}) { + const [open, setOpen] = useState(false); + const [gpuType, setGpuType] = useState(gpuTypes?.[0] ?? ""); + const [mode, setMode] = useState(modes?.[0] ?? ""); + const [file, setFile] = useState(null); + const [status, setStatus] = useState< + | { kind: "idle" } + | { kind: "uploading" } + | { kind: "error"; msg: string } + | { kind: "ok"; msg: string } + >({ kind: "idle" }); + + const fileInputRef = useRef(null); + + const canSubmit = useMemo( + () => !!file && !!gpuType && !!mode, + [file, gpuType, mode], + ); + + function resetForm() { + setFile(null); + setStatus({ kind: "idle" }); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + + function validatePythonFile(f: File): string | null { + const MAX_MB = 5; + const name = f.name.toLowerCase(); + if (!name.endsWith(".py")) return "Please select a .py file."; + if (f.size > MAX_MB * 1024 * 1024) return `File too large (> ${MAX_MB} MB)`; + return null; + } + + function handlePickFile(e: React.ChangeEvent) { + const f = e.target.files?.[0] ?? null; + if (!f) return; + const err = validatePythonFile(f); + if (err) { + setStatus({ kind: "error", msg: err }); + setFile(null); + return; + } + setStatus({ kind: "idle" }); + setFile(f); + } + + async function handleSubmit() { + if (!canSubmit || !file) return; + setStatus({ kind: "uploading" }); + + try { + const form = new FormData(); + form.set("leaderboard_id", String(leaderboardId)); + form.set("leaderboard", leaderboardName); + form.set("gpu_type", gpuType); + form.set("submission_mode", mode); // <-- match backend field name + form.set("file", file, file.name); // <-- required + + const result = await submitFile(form); + + setStatus({ + kind: "ok", + msg: result?.message ?? "Submitted successfully.", + }); + + setTimeout(() => { + setOpen(false); + resetForm(); + }, 600); + } catch (e: any) { + setStatus({ kind: "error", msg: e?.message || "Submission failed" }); + } + } + return ( + <> + + { + setOpen(false); + resetForm(); + }} + maxWidth="sm" + fullWidth + > + Submit to Leaderboard + + + Choose a .py file and set GPU type & mode. + + + + + GPU Type + + + + + Mode + + + + + +
{file?.name ?? ""}
+
+ {status.kind === "error" && ( + + {status.msg} + + )} + {status.kind === "ok" && ( + + {status.msg} + + )} +
+
+ + + + + +
+ + ); +} + +// Centralized sx styles +const styles = { + triggerBtn: { borderRadius: 2, textTransform: "none" }, + title: { fontWeight: 700 }, + hint: { mt: 0.5, mb: 2 }, + stack: { mt: 1 }, + fileBtn: { textTransform: "none", mb: 1 }, + fileText: { "& .MuiInputBase-input": { cursor: "default" } }, + actions: { px: 3, pb: 2 }, +} as const; diff --git a/frontend/src/pages/leaderboard/components/ListSubmissionsSidePanel.tsx b/frontend/src/pages/leaderboard/components/ListSubmissionsSidePanel.tsx new file mode 100644 index 00000000..36aeef9a --- /dev/null +++ b/frontend/src/pages/leaderboard/components/ListSubmissionsSidePanel.tsx @@ -0,0 +1,202 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Box, + Typography, + IconButton, + List, + ListItemButton, + ListItemText, + Chip, + CircularProgress, + Alert, + Pagination, + Tooltip, +} from "@mui/material"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { fetchUserSubmissions } from "../../../api/api"; +import { fetcherApiCallback } from "../../../lib/hooks/useApi"; + +type Submission = { + submission_id: number; + file_name?: string | null; + submitted_at: string; // ISO + status?: string | null; +}; + +type Props = { + leaderboardId: number | string; + leaderboardName: string; + userId: number | string; + pageSize?: number; // default 10 +}; + +const styles = { + root: { + width: "100%", + p: 2, + display: "flex", + flexDirection: "column", + height: "100%", + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + mb: 1, + }, + loading: { + display: "flex", + justifyContent: "center", + mt: 3, + }, + listWrapper: { + flex: 1, + overflowY: "auto", + mt: 1, + }, + footer: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + mt: 1, + }, +}; + +export default function ListSubmissionSidePanel({ + leaderboardId, + userId, + pageSize = 10, +}: Props) { + const [page, setPage] = useState(1); + + const { data, loading, error, errorStatus, call } = + fetcherApiCallback(fetchUserSubmissions); + + // reset page when inputs affecting the result set change + useEffect(() => { + setPage(1); + }, [leaderboardId, userId, pageSize]); + + // fetch when inputs or page change + useEffect(() => { + if (!leaderboardId || !userId) return; + call(leaderboardId, userId, page, pageSize); + }, [leaderboardId, userId, page, pageSize, call]); + + let totalPages = + data?.limit && data?.total ? Math.ceil(data?.total / data?.limit) : 1; + let items: Submission[] = data?.items ?? []; + let total: number = data?.total ?? 0; + + console.log( + "total pages", + totalPages, + "total", + total, + "items", + items.length, + "page", + page, + "pageSize", + pageSize, + ); + + // clamp page if server says there are fewer pages now + useEffect(() => { + if (page > totalPages) setPage(totalPages || 1); + }, [totalPages, page]); + + const showingRange = useMemo(() => { + const start = (page - 1) * pageSize + 1; + const end = Math.min(page * pageSize, total); + if (total === 0) return "0"; + return `${start}-${end} / ${total}`; + }, [page, pageSize, total]); + + const statusColor = (s?: string | null) => { + const v = (s || "").toLowerCase(); + if (v.includes("run")) return "warning"; + if (v.includes("ok") || v.includes("succ")) return "success"; + if (v.includes("fail") || v.includes("err")) return "error"; + return "default"; + }; + + return ( + + {/* Header */} + + Your submission history + + + + leaderboardId && + userId && + call(leaderboardId, userId, page, pageSize) + } + size="small" + sx={{ mr: 1 }} + disabled={loading || !leaderboardId || !userId} + > + + + + + + + {/* Loading / Error */} + {loading && ( + + + + )} + {!loading && error && ( + + Failed to load submissions{errorStatus ? ` (${errorStatus})` : ""}:{" "} + {error} + + )} + + {/* List */} + + {!loading && !error && items.length === 0 && ( + + No submissions. + + )} + + {items.map((s) => ( + + + + + ))} + + + + {/* Footer: pagination + range */} + + + {showingRange} + + !loading && setPage(p)} + disabled={loading || totalPages <= 1 || !leaderboardId || !userId} + showFirstButton + showLastButton + /> + + + ); +} diff --git a/kernelboard/api/__init__.py b/kernelboard/api/__init__.py index a4ff3318..f598577f 100644 --- a/kernelboard/api/__init__.py +++ b/kernelboard/api/__init__.py @@ -1,11 +1,14 @@ from requests import auth from flask import Blueprint from werkzeug.exceptions import HTTPException +from kernelboard.api.submission import submission from kernelboard.lib.status_code import http_error, http_success from kernelboard.api.leaderboard import leaderboard_bp from kernelboard.api.leaderboard_summaries import leaderboard_summaries_bp from kernelboard.api.news import news_bp from kernelboard.api.auth import auth_bp +from kernelboard.api.submission import submission_bp + def create_api_blueprint(): @@ -62,5 +65,5 @@ def get_about(): api.register_blueprint(news_bp) api.register_blueprint(leaderboard_summaries_bp) api.register_blueprint(auth_bp) - + api.register_blueprint(submission_bp) return api diff --git a/kernelboard/api/auth.py b/kernelboard/api/auth.py index 260b7fc0..29e50501 100644 --- a/kernelboard/api/auth.py +++ b/kernelboard/api/auth.py @@ -244,7 +244,7 @@ def callback(provider: str): session["display_name"] = username session["avatar_url"] = _discord_avatar_url(identity, data.get("avatar")) - # ensure user exists and has web_token_id + # ensure user exists and has web_auth_id # if not, update the user with the new token ensure_user_info_with_token(identity, username) @@ -270,6 +270,6 @@ def logout(): @auth_bp.get("/me") def me(): res = get_user_info_from_session() - res.update("login_url", url_for("api.auth.auth", provider="discord")) - res.update("logout_url", url_for("api.auth.logout")) + res.update({"login_url": url_for("api.auth.auth", provider="discord")}) + res.update({"logout_url": url_for("api.auth.logout")}) return http_success(res) diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index 3d73044c..a9020598 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -1,30 +1,29 @@ +from ast import Dict import http +from typing import Any, List, Optional, Tuple +from blinker import ANY from flask import Blueprint, request, jsonify, current_app import requests from kernelboard.lib.auth_utils import get_id_and_username_from_session, get_user_token, is_auth +from kernelboard.lib.db import get_db_connection from kernelboard.lib.error import ValidationError, validate_required_fields from kernelboard.lib.file_handler import get_submission_file_info -from kernelboard.lib.status_code import http_error +from kernelboard.lib.status_code import http_error, http_success import logging logger = logging.getLogger(__name__) -sub_bp = Blueprint("submisison_api", __name__, url_prefix="/submission") +submission_bp = Blueprint("submisison_api", __name__) - -REQUIRED_SUBMISSION_REQUEST_FIELDS = ["file","leaderboard_","gpu_type", "submission_mode"] -SUBMISSION_API_BASE = "https://your.external.api/submission" +REQUIRED_SUBMISSION_REQUEST_FIELDS = ["leaderboard_id","leaderboard","gpu_type", "submission_mode"] +SUBMISSION_API_BASE = "http://0.0.0.0:8000/submission" WEB_AUTH_HEADER = "X-Web-Auth-Id" MAX_CONTENT_LENGTH = 20 * 1024 * 1024 # 20MB max file size - -@sub_bp.before_app_request -def _limit_size(): - current_app.config.setdefault("MAX_CONTENT_LENGTH", MAX_CONTENT_LENGTH) - -@sub_bp.route("/submission", methods=["POST"]) +@submission_bp.route("/submission", methods=["POST"]) def submission(): # make sure user is logged in + logger.info("submission received") if not is_auth(): return http_error( message="cannnot get user id, please log in first, if this is unexpected, please contact the gpumode administrator", @@ -32,7 +31,7 @@ def submission(): status_code=http.HTTPStatus.UNAUTHORIZED, ) user_id, username = get_id_and_username_from_session() - + web_token = get_user_token(user_id) if not web_token: return http_error( @@ -40,12 +39,15 @@ def submission(): code=10000 + http.HTTPStatus.INTERNAL_SERVER_ERROR.value, status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, ) + logger.info("submission request from user %s" % username) req = request.form.to_dict() + logger.info(f"submission request from user {username} with request {req}") try: validate_required_fields(req, REQUIRED_SUBMISSION_REQUEST_FIELDS) filename, mime, f = get_submission_file_info(request) except ValidationError as e: + logger.error(f"Invalid submission request: {e}") return http_error( message=e.message, code=e.code, @@ -53,6 +55,7 @@ def submission(): **e.extras, ) except Exception as e: + logger.error(f"Failed to get submission file info: {e}") return http_error( message=str(e), code=10000 + http.HTTPStatus.INTERNAL_SERVER_ERROR.value, @@ -62,7 +65,9 @@ def submission(): # form post request to external api gpu_type = request.form.get("gpu_type") submission_mode = request.form.get("submission_mode") - leaderboard_name = request.form.get("leaderboard_name") + leaderboard_name = request.form.get("leaderboard") + + logger.info(f"submission request from user {username} for leaderboard {leaderboard_name} with gpu type {gpu_type} and submission mode {submission_mode}") url = f"{SUBMISSION_API_BASE}/{leaderboard_name}/{gpu_type}/{submission_mode}" @@ -77,13 +82,143 @@ def submission(): try: resp = requests.post(url, headers=headers, files=files, timeout=180) except requests.RequestException as e: + logger.error(f"forward failed: {e}") return jsonify({"error": f"forward failed: {e}"}), 502 - # Pass-through response try: - data = resp.json() - return jsonify(data), resp.status_code + payload = resp.json() + message = payload.get("message") or payload.get("detail") or resp.reason + logger.info(f"submission response: {payload}") + if resp.status_code == 200: + logger.info("submission success, {payload}") + return http_success(message="submission success, please wait for the result", data=payload) + else: + return http_error( + message=message, + code=10000 + resp.status_code, + status_code=http.HTTPStatus(resp.status_code), + data=payload, + ) except ValueError: - return resp.text, resp.status_code, { - "Content-Type": resp.headers.get("Content-Type", "text/plain") - } + message = resp.text or resp.reason + return http_error( + message=f"submission failed due to: {e}", + code=10000 + http.HTTPStatus.BAD_REQUEST.value, + status_code=http.HTTPStatus.BAD_REQUEST, + ) + except Exception as e: + logger.error(f"unexpected error happened: {e}") + return http_error( + message=f"unexpected error happened: {e}", + code=10000 + http.HTTPStatus.INTERNAL_SERVER_ERROR.value, + status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + +@submission_bp.route("/submissions", methods=["GET"]) +def list_submissions(): + """ + GET /submissions?leaderboard_id=123&&limit=20&offset=0 + """ + logger.info("list submissions received") + if not is_auth(): + return http_error( + message="cannnot get user id, please log in first, if this is unexpected, please contact the gpumode administrator", + code=10000 + http.HTTPStatus.UNAUTHORIZED.value, + status_code=http.HTTPStatus.UNAUTHORIZED, + ) + user_id, username = get_id_and_username_from_session() + leaderboard_id = request.args.get("leaderboard_id", type=int) + limit = request.args.get("limit", default=20, type=int) + offset = request.args.get("offset", default=0, type=int) + + if leaderboard_id is None or user_id is None: + return http_error( + message="leaderboard_id and user_id are required (int)", + code=10000 + http.HTTPStatus.BAD_REQUEST.value, + status_code=http.HTTPStatus.BAD_REQUEST, + ) + # clamp limit + limit = max(1, min(limit, 100)) + try: + items, total = list_user_submissions_with_status( + leaderboard_id=leaderboard_id, + user_id=user_id, + limit=limit, + offset=offset, + ) + except Exception as e: + logger.error(f"failed to fetch submissions for leaderboard {leaderboard_id}: {e}") + return http_error( + message=f"failed to fetch submissions for leaderboard {leaderboard_id}", + code=10000 + http.HTTPStatus.INTERNAL_SERVER_ERROR.value, + status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return http_success( + data = { + "items": items, + "total": total, + "limit": limit, + "offset": offset, + }, + ) + +def list_user_submissions_with_status( + leaderboard_id: int, + user_id: int, + limit: int = 20, + offset: int = 0, +) -> Tuple[List[dict[str, Any]], int]: + conn = get_db_connection() + try: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + s.id AS submission_id, + s.leaderboard_id, + s.file_name, + s.submission_time AS submitted_at, + j.status, + j.error, + j.last_heartbeat, + j.created_at AS job_created_at + FROM leaderboard.submission AS s + LEFT JOIN leaderboard.submission_job_status AS j + ON j.submission_id = s.id + WHERE s.leaderboard_id = %s + AND s.user_id = %s + ORDER BY s.submission_time DESC + LIMIT %s OFFSET %s + """, + (leaderboard_id, user_id, limit, offset), + ) + rows = cur.fetchall() + items = [ + { + "submission_id": r[0], + "leaderboard_id": r[1], + "file_name": r[2], + "submitted_at": r[3], + "status": r[4], + "error": r[5], + "last_heartbeat": r[6], + "job_created_at": r[7], + } + for r in rows + ] + cur.execute( + """ + SELECT COUNT(*) AS total + FROM leaderboard.submission AS s + WHERE s.leaderboard_id = %s + AND s.user_id = %s + """, + (leaderboard_id, user_id), + ) + total = cur.fetchone()[0] + return items, total + finally: + conn.close() diff --git a/kernelboard/lib/auth_utils.py b/kernelboard/lib/auth_utils.py index 88789985..ee362dbf 100644 --- a/kernelboard/lib/auth_utils.py +++ b/kernelboard/lib/auth_utils.py @@ -56,23 +56,23 @@ def ensure_user_info_with_token(user_id: int, user_name: str) -> Optional[Any]: """ Idempotent behavior: - If user does not exist -> INSERT with new token and return the row. - - If user exists and web_token_id IS NULL -> UPDATE to set token and return the row. - - If user exists and web_token_id IS NOT NULL -> do not overwrite; just SELECT and return existing row. + - If user exists and web_auth_id IS NULL -> UPDATE to set token and return the row. + - If user exists and web_auth_id IS NOT NULL -> do not overwrite; just SELECT and return existing row. """ new_token = secrets.token_hex(16) conn = get_db_connection() try: with conn: # automatically commit on success / rollback on error with conn.cursor() as cur: - # Attempt "insert or update only if web_token_id is NULL" + # Attempt "insert or update only if web_auth_id is NULL" cur.execute( """ - INSERT INTO leaderboard.user_info (id, user_name, web_token_id) + INSERT INTO leaderboard.user_info (id, user_name, web_auth_id) VALUES (%s, %s, %s) ON CONFLICT (id) DO UPDATE - SET web_token_id = EXCLUDED.web_token_id - WHERE leaderboard.user_info.web_token_id IS NULL - RETURNING id, user_name, web_token_id + SET web_auth_id = EXCLUDED.web_auth_id + WHERE leaderboard.user_info.web_auth_id IS NULL + RETURNING id, user_name, web_auth_id """, (user_id, user_name, new_token), ) @@ -85,7 +85,7 @@ def ensure_user_info_with_token(user_id: int, user_name: str) -> Optional[Any]: # if no upsert was done, fetch the existing row and return it cur.execute( """ - SELECT id, user_name, web_token_id + SELECT id, user_name, web_auth_id FROM leaderboard.user_info WHERE id = %s """, @@ -102,7 +102,7 @@ def get_user_token(user_id: int) -> Optional[str]: with conn.cursor() as cur: cur.execute( """ - SELECT web_token_id + SELECT web_auth_id FROM leaderboard.user_info WHERE id = %s """, @@ -113,3 +113,5 @@ def get_user_token(user_id: int) -> Optional[str]: return row[0] if row else None finally: conn.close() + + diff --git a/kernelboard/lib/file_handler.py b/kernelboard/lib/file_handler.py index a41f8d6e..c5e02f68 100644 --- a/kernelboard/lib/file_handler.py +++ b/kernelboard/lib/file_handler.py @@ -1,12 +1,7 @@ -import ast, re, magic -from typing import List -from flask import current_app +import ast, re +import mimetypes from werkzeug.utils import secure_filename -from errors import ( - ValidationError, MissingRequiredFieldError, InvalidPythonExtensionError, - InvalidMimeError, InvalidSyntaxError -) -from kernelboard.lib.error import validate_required_fields +from kernelboard.lib.error import InvalidMimeError, InvalidSyntaxError,InvalidPythonExtensionError,MissingRequiredFieldError ALLOWED_EXTS = {".py"} ALLOWED_PYTHON_MIMES = {"text/x-python", "text/x-script.python", "text/plain"} @@ -24,33 +19,67 @@ def get_submission_file_info(request): "missing required submission python file, if this is unexpected, please contact the gpumode administrator" ) + # Validate extension ext = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else "" if ext not in ALLOWED_EXTS: raise InvalidPythonExtensionError() - # MIME sniff (peek) + # Peek first 2KB for quick checks and MIME guessing sample = f.stream.read(2048) f.stream.seek(0) - mime = magic.from_buffer(sample, mime=True) or "" - if mime not in ALLOWED_PYTHON_MIMES: - raise InvalidMimeError(mime=mime) - # Full bounded read for validation + # Reject binary content quickly + if b"\x00" in sample or _TEXT_CTRL_RE.search(sample): + raise InvalidMimeError(message="binary content detected; not Python text") + + # Guess MIME type without libmagic + mime = _guess_python_mime(filename, sample) + + # Full read (bounded by MAX_CONTENT_LENGTH) raw = f.stream.read(MAX_CONTENT_LENGTH + 1) f.stream.seek(0) if not raw: raise InvalidSyntaxError("file is empty") if len(raw) > MAX_CONTENT_LENGTH: raise InvalidSyntaxError(f"file too large (> {MAX_CONTENT_LENGTH} bytes)") - if _TEXT_CTRL_RE.search(raw) or b"\x00" in raw: + if b"\x00" in raw or _TEXT_CTRL_RE.search(raw): raise InvalidMimeError(message="binary content detected; not Python text") + # Decode as UTF-8 try: text = raw.decode("utf-8", errors="strict") - ast.parse(text, filename=filename, mode="exec") except UnicodeDecodeError: raise InvalidSyntaxError("file is not valid UTF-8 text") + + # Validate syntax with AST + try: + ast.parse(text, filename=filename, mode="exec") except SyntaxError as e: raise InvalidSyntaxError(f"{e.msg} at line {e.lineno}") return filename, mime, f + + +def _guess_python_mime(filename: str, sample: bytes) -> str: + """ + Guess a Python MIME type without libmagic. + 1. If extension is .py/.pyw → assume "text/x-python". + 2. Otherwise, use mimetypes.guess_type. + 3. If the first line contains a python shebang → "text/x-python". + 4. Fallback to "application/octet-stream". + """ + if filename.lower().endswith((".py", ".pyw")): + return "text/x-python" + + mime, _ = mimetypes.guess_type(filename) + if mime: + return mime + + try: + first_line = sample.splitlines()[0] if sample else b"" + except Exception: + first_line = b"" + if first_line.startswith(b"#!") and b"python" in first_line.lower(): + return "text/x-python" + + return "application/octet-stream" diff --git a/kernelboard/lib/time.py b/kernelboard/lib/time.py index 7c1a3e2a..34761dd0 100644 --- a/kernelboard/lib/time.py +++ b/kernelboard/lib/time.py @@ -29,6 +29,13 @@ def _to_time_left(deadline: str | datetime, now: datetime) -> str | None: hour_label = "hour" if hours == 1 else "hours" return f"{days} {day_label} {hours} {hour_label} remaining" +def is_ended(deadline: str | datetime, now: datetime) -> bool: + """ + Check if deadline has passed. + + Returns: True if deadline has passed, otherwise False. + """ + return _to_time_left(deadline, now) == "ended" def format_datetime(dt: datetime | str) -> str: """ diff --git a/requirements.txt b/requirements.txt index 89916a38..7220ad3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,4 +22,3 @@ requests>=2.32.3,<3.0.0 urllib3>=2.4.0,<3.0.0 Werkzeug>=3.1.3,<3.2.0 pyyaml>=6.0.1 -python-magic>=0.4.27 diff --git a/tests/conftest.py b/tests/conftest.py index 65b362cd..294328c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -117,7 +117,7 @@ def db_server(): # Load data.sql into the template database: result = subprocess.run( [ - "psql", + "/opt/homebrew/opt/postgresql@16/bin/psql", "-h", "localhost", "-U", @@ -298,3 +298,11 @@ def client(app): @pytest.fixture def runner(app): return app.test_cli_runner() + +@pytest.fixture(autouse=True) +def set_env(monkeypatch): + monkeypatch.setenv("DATABASE_URL", get_test_db_info()["db_url"]) + monkeypatch.setenv("DISCORD_CLIENT_ID", "test") + monkeypatch.setenv("DISCORD_CLIENT_SECRET", "test") + monkeypatch.setenv("REDIS_URL", get_test_redis_url(get_test_redis_port())) + monkeypatch.setenv("SECRET_KEY", "test-secret") diff --git a/tests/data.sql b/tests/data.sql index 76a4633c..da7855d5 100644 --- a/tests/data.sql +++ b/tests/data.sql @@ -9188,7 +9188,8 @@ ALTER TABLE ONLY leaderboard.submission ADD CONSTRAINT submission_leaderboard_id_fkey FOREIGN KEY (leaderboard_id) REFERENCES leaderboard.leaderboard(id); +ALTER TABLE ONLY leaderboard.user_info + ADD COLUMN IF NOT EXISTS web_auth_id VARCHAR(255) DEFAULT NULL; -- -- PostgreSQL database dump complete -- - From 27694ae6a02391eace89314d1a3822330039fb85 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Mon, 25 Aug 2025 14:01:13 -0700 Subject: [PATCH 03/17] echo variables --- kernelboard/api/leaderboard.py | 1 - tests/data.sql | 13 +++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/kernelboard/api/leaderboard.py b/kernelboard/api/leaderboard.py index 19ee337b..142199d0 100644 --- a/kernelboard/api/leaderboard.py +++ b/kernelboard/api/leaderboard.py @@ -33,7 +33,6 @@ def leaderboard(leaderboard_id: int): res = to_api_leaderboard_item(data) return http_success(res) - # converts db record to api def to_api_leaderboard_item(data: dict[str, Any]): leaderboard_data = data["leaderboard"] diff --git a/tests/data.sql b/tests/data.sql index da7855d5..7bbbb37b 100644 --- a/tests/data.sql +++ b/tests/data.sql @@ -9190,6 +9190,19 @@ ALTER TABLE ONLY leaderboard.submission ALTER TABLE ONLY leaderboard.user_info ADD COLUMN IF NOT EXISTS web_auth_id VARCHAR(255) DEFAULT NULL; + +CREATE TABLE IF NOT EXISTS leaderboard.submission_job_status ( + id SERIAL PRIMARY KEY, + submission_id INTEGER NOT NULL + REFERENCES leaderboard.submission(id) + ON DELETE CASCADE, + status VARCHAR(255) DEFAULT NULL, -- pending | running | succeeded | failed | timed_out + error TEXT DEFAULT NULL, -- error details if failed + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- creation timestamp + last_heartbeat TIMESTAMPTZ DEFAULT NULL, -- updated periodically by worker + CONSTRAINT uq_submission_job_status_submission_id + UNIQUE (submission_id) -- one-to-one with submission + ); -- -- PostgreSQL database dump complete -- From 35176737dc9b5d01720d44db54d8bcf12f3a45fa Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Mon, 25 Aug 2025 15:04:41 -0700 Subject: [PATCH 04/17] echo variables --- README.md | 20 +++++++++-- .../src/pages/leaderboard/Leaderboard.tsx | 2 -- .../components/ListSubmissionsSidePanel.tsx | 2 +- kernelboard/api/auth.py | 6 +--- kernelboard/api/leaderboard.py | 2 -- kernelboard/api/news.py | 2 +- kernelboard/api/submission.py | 33 +++++++++++++------ kernelboard/lib/auth_utils.py | 1 - kernelboard/lib/env.py | 1 + kernelboard/lib/file_handler.py | 3 +- tests/api/test_news_api.py | 1 - tests/conftest.py | 1 + 12 files changed, 48 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 91854feb..c3cfbc3d 100644 --- a/README.md +++ b/README.md @@ -48,14 +48,17 @@ Here's how to get started: 5. You'll also need a Redis instance to store sessions. Again, feel free to set this up in whatever way works best for you. -6. Finally, create a .env file in the root directory of your sandbox with - SECRET_KEY, DATABASE_URL, and REDIS_URL entries. The secret key can be +6. [Optional] if you want to test submission end to end, you need to run and setup [discord-cluster-manager](https://github.com/gpu-mode/discord-cluster-manager), otherwise, just set DISCORD_CLUSTER_MANAGER_API_BASE_URL to a dummy url in .env file. + +7. Finally, create a .env file in the root directory of your sandbox with + SECRET_KEY, DATABASE_URL, REDIS_and DISCORD_CLUSTER_MANAGER_API_BASE_URL URL entries. The secret key can be anything you like; `dev` will work well. ```env SECRET_KEY=dev DATABASE_URL=postgresql://user:password@host:port/kernelboard REDIS_URL=redis://localhost:6379 + DISCORD_CLUSTER_MANAGER_API_BASE_URL=http://localhost:8080 ``` ## Running tests @@ -154,3 +157,16 @@ cd frontend && npm run dev 3. Open the React dev server (e.g. `http://localhost:5173/kb/about`) in your browser. > In this mode, the React app is served separately with hot-reloading. Use it for faster iteration during development. + +### Test submission +we pass the submission job to [discord-cluster-manager](https://github.com/gpu-mode/discord-cluster-manager), which will run the job and return the result to the gpumode backend. To test locally end-to-end, you should follow the instructions in the [discord-cluster-manager](https://github.com/gpu-mode/discord-cluster-manager) repo to set up the server locally. + + then run the server: +```bash +python src/kernelbot/main.py --debug +``` +and pass the url to your .env file: +```env +DISCORD_CLUSTER_MANAGER_API_BASE_URL=http://localhost:8080 +``` +Please notice, you need to make sure both of them connects to same db instance. diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index d38fb872..5f06deaa 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -68,8 +68,6 @@ export default function Leaderboard() { const isAuthed = !!(me && me.authenticated); const userId = me?.user?.identity ?? null; - const [openSubs, setOpenSubs] = useState(false); - // Sync tab state with query parameter const [searchParams, setSearchParams] = useSearchParams(); const initialTabFromUrl = ((): TabKey => { diff --git a/frontend/src/pages/leaderboard/components/ListSubmissionsSidePanel.tsx b/frontend/src/pages/leaderboard/components/ListSubmissionsSidePanel.tsx index 36aeef9a..0ba58f2f 100644 --- a/frontend/src/pages/leaderboard/components/ListSubmissionsSidePanel.tsx +++ b/frontend/src/pages/leaderboard/components/ListSubmissionsSidePanel.tsx @@ -175,7 +175,7 @@ export default function ListSubmissionSidePanel({ size="small" variant="outlined" color={statusColor(s.status) as any} - label={s.status || "unknown"} + label={s.status || "submitted via cli or discor-bot"} /> ))} diff --git a/kernelboard/api/auth.py b/kernelboard/api/auth.py index 29e50501..96dcf8ef 100644 --- a/kernelboard/api/auth.py +++ b/kernelboard/api/auth.py @@ -1,17 +1,13 @@ from __future__ import annotations -from ast import Tuple import os import secrets -from typing import Any, Optional from urllib.parse import urlencode import requests from flask import ( Blueprint, - abort, current_app as app, - jsonify, redirect, request, session, @@ -193,7 +189,7 @@ def callback(provider: str): }, timeout=10, ) - except requests.RequestException as e: + except requests.RequestException: app.logger.exception("Token exchange request failed") return redirect_with_error( "request_error", "Network error during token exchange" diff --git a/kernelboard/api/leaderboard.py b/kernelboard/api/leaderboard.py index 142199d0..62ec1313 100644 --- a/kernelboard/api/leaderboard.py +++ b/kernelboard/api/leaderboard.py @@ -1,10 +1,8 @@ from typing import Any from flask import Blueprint -from flask import Blueprint from kernelboard.lib.db import get_db_connection from kernelboard.lib.time import to_time_left from kernelboard.lib.status_code import http_error, http_success -from kernelboard.lib.status_code import http_error, http_success from http import HTTPStatus diff --git a/kernelboard/api/news.py b/kernelboard/api/news.py index 07e5de2b..b3cbc37a 100644 --- a/kernelboard/api/news.py +++ b/kernelboard/api/news.py @@ -1,7 +1,7 @@ from http import HTTPStatus import os import yaml -from flask import Blueprint, jsonify, current_app +from flask import Blueprint, current_app from kernelboard.lib.status_code import HttpError, http_error, http_success from datetime import datetime import logging diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index a9020598..d3752530 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -1,8 +1,6 @@ -from ast import Dict import http -from typing import Any, List, Optional, Tuple -from blinker import ANY -from flask import Blueprint, request, jsonify, current_app +from typing import Any, List, Tuple +from flask import Blueprint, request, jsonify import requests from kernelboard.lib.auth_utils import get_id_and_username_from_session, get_user_token, is_auth from kernelboard.lib.db import get_db_connection @@ -10,13 +8,14 @@ from kernelboard.lib.file_handler import get_submission_file_info from kernelboard.lib.status_code import http_error, http_success import logging - +import os logger = logging.getLogger(__name__) submission_bp = Blueprint("submisison_api", __name__) REQUIRED_SUBMISSION_REQUEST_FIELDS = ["leaderboard_id","leaderboard","gpu_type", "submission_mode"] -SUBMISSION_API_BASE = "http://0.0.0.0:8000/submission" + +# official one: https://discord-cluster-manager-1f6c4782e60a.herokuapp.com/submission WEB_AUTH_HEADER = "X-Web-Auth-Id" MAX_CONTENT_LENGTH = 20 * 1024 * 1024 # 20MB max file size @@ -66,10 +65,9 @@ def submission(): gpu_type = request.form.get("gpu_type") submission_mode = request.form.get("submission_mode") leaderboard_name = request.form.get("leaderboard") - logger.info(f"submission request from user {username} for leaderboard {leaderboard_name} with gpu type {gpu_type} and submission mode {submission_mode}") - - url = f"{SUBMISSION_API_BASE}/{leaderboard_name}/{gpu_type}/{submission_mode}" + base = get_cluster_manager_endpoint() + url = f"{base}/submission/{leaderboard_name}/{gpu_type}/{submission_mode}" files = { # requests expects (filename, fileobj, content_type) @@ -120,6 +118,11 @@ def list_submissions(): """ GET /submissions?leaderboard_id=123&&limit=20&offset=0 """ + # TODO(elainewy): currently we only fetch the user's all submissions, but we do not have details of: + # submit method: discord-bot vs cli vs web + # submit request info: mode and gpu type + # this could be a followup to provide more information + logger.info("list submissions received") if not is_auth(): return http_error( @@ -127,7 +130,7 @@ def list_submissions(): code=10000 + http.HTTPStatus.UNAUTHORIZED.value, status_code=http.HTTPStatus.UNAUTHORIZED, ) - user_id, username = get_id_and_username_from_session() + user_id, _ = get_id_and_username_from_session() leaderboard_id = request.args.get("leaderboard_id", type=int) limit = request.args.get("limit", default=20, type=int) offset = request.args.get("offset", default=0, type=int) @@ -222,3 +225,13 @@ def list_user_submissions_with_status( return items, total finally: conn.close() + + +def get_cluster_manager_endpoint(): + """ + Return OAuth2 provider information. + """ + env_var = os.getenv("DISCORD_CLUSTER_MANAGER_API_BASE_URL","") + if not env_var: + logger.warning("DISCORD_CLUSTER_MANAGER_API_BASE_URL is not set!!!") + return env_var diff --git a/kernelboard/lib/auth_utils.py b/kernelboard/lib/auth_utils.py index ee362dbf..b9d3ba11 100644 --- a/kernelboard/lib/auth_utils.py +++ b/kernelboard/lib/auth_utils.py @@ -1,5 +1,4 @@ -from ast import Tuple import secrets from typing import Any, Optional diff --git a/kernelboard/lib/env.py b/kernelboard/lib/env.py index 3a1c4baa..4eb57345 100644 --- a/kernelboard/lib/env.py +++ b/kernelboard/lib/env.py @@ -13,6 +13,7 @@ def check_env_vars(): "DISCORD_CLIENT_SECRET", "REDIS_URL", "SECRET_KEY", + "DISCORD_CLUSTER_MANAGER_API_BASE_URL", ] missing_env_vars = [var for var in required_env_vars if os.getenv(var) is None] diff --git a/kernelboard/lib/file_handler.py b/kernelboard/lib/file_handler.py index c5e02f68..586b6929 100644 --- a/kernelboard/lib/file_handler.py +++ b/kernelboard/lib/file_handler.py @@ -1,4 +1,5 @@ -import ast, re +import ast +import re import mimetypes from werkzeug.utils import secure_filename from kernelboard.lib.error import InvalidMimeError, InvalidSyntaxError,InvalidPythonExtensionError,MissingRequiredFieldError diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py index 5310b9a1..7db1231d 100644 --- a/tests/api/test_news_api.py +++ b/tests/api/test_news_api.py @@ -1,5 +1,4 @@ from http import HTTPStatus -import pytest from unittest.mock import patch, mock_open diff --git a/tests/conftest.py b/tests/conftest.py index 294328c9..95d393f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -306,3 +306,4 @@ def set_env(monkeypatch): monkeypatch.setenv("DISCORD_CLIENT_SECRET", "test") monkeypatch.setenv("REDIS_URL", get_test_redis_url(get_test_redis_port())) monkeypatch.setenv("SECRET_KEY", "test-secret") + monkeypatch.setenv("DISCORD_CLUSTER_MANAGER_API_BASE_URL", "http://discord_cluster_manager:8000") From b4ead44b09e77192264f851d6c3150ccaa36e3a7 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Mon, 25 Aug 2025 21:31:42 -0700 Subject: [PATCH 05/17] echo variables --- frontend/src/api/api.ts | 7 - .../components/app-layout/NavUserProfile.tsx | 1 + .../common/LoadingCircleProgress.tsx | 14 + frontend/src/components/common/loading.tsx | 10 +- .../pages/leaderboard/Leaderboard.test.tsx | 384 ++++++++++++------ .../src/pages/leaderboard/Leaderboard.tsx | 51 +-- .../components/LeaderboardSubmit.test.tsx | 251 ++++++++++++ ...SubmitDialog.tsx => LeaderboardSubmit.tsx} | 122 ++++-- .../ListSubmissionsSidePanel.tsx | 123 +++--- .../submission-history/SubmissionDoneCell.tsx | 17 + .../SubmissionStatusChip.tsx | 35 ++ kernelboard/api/submission.py | 88 ++-- kernelboard/lib/error.py | 3 + kernelboard/lib/file_handler.py | 2 +- tests/api/test_submission_api.py | 374 +++++++++++++++++ tests/data.sql | 6 +- 16 files changed, 1181 insertions(+), 307 deletions(-) create mode 100644 frontend/src/components/common/LoadingCircleProgress.tsx create mode 100644 frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx rename frontend/src/pages/leaderboard/components/{LeaderboardSubmitDialog.tsx => LeaderboardSubmit.tsx} (74%) rename frontend/src/pages/leaderboard/components/{ => submission-history}/ListSubmissionsSidePanel.tsx (59%) create mode 100644 frontend/src/pages/leaderboard/components/submission-history/SubmissionDoneCell.tsx create mode 100644 frontend/src/pages/leaderboard/components/submission-history/SubmissionStatusChip.tsx create mode 100644 tests/api/test_submission_api.py diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 064b708f..d852e231 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -75,13 +75,6 @@ export async function logout(): Promise { } export async function submitFile(form: FormData) { - for (const [k, v] of form.entries()) { - if (v instanceof File) { - console.log(`${k}: File(name=${v.name}, size=${v.size}, type=${v.type})`); - } else { - console.log(`${k}:`, v); - } - } const resp = await fetch("/api/submission", { method: "POST", body: form, diff --git a/frontend/src/components/app-layout/NavUserProfile.tsx b/frontend/src/components/app-layout/NavUserProfile.tsx index a61f58e7..fb36635b 100644 --- a/frontend/src/components/app-layout/NavUserProfile.tsx +++ b/frontend/src/components/app-layout/NavUserProfile.tsx @@ -31,6 +31,7 @@ export default function NavUserProfile() { const logoutAndRefresh = useAuthStore((s) => s.logoutAndRefresh); const [anchorEl, setAnchorEl] = useState(null); + const [notification, setNotification] = useState<{ open: boolean; message: string; diff --git a/frontend/src/components/common/LoadingCircleProgress.tsx b/frontend/src/components/common/LoadingCircleProgress.tsx new file mode 100644 index 00000000..923a68d8 --- /dev/null +++ b/frontend/src/components/common/LoadingCircleProgress.tsx @@ -0,0 +1,14 @@ +import { CircularProgress } from "@mui/material"; + +export default function LoadingCircleProgress({ + message = "loading...", +}: { + message: string; +}) { + return ( + <> + + {message} + + ); +} diff --git a/frontend/src/components/common/loading.tsx b/frontend/src/components/common/loading.tsx index 3fcd1e92..ec571a58 100644 --- a/frontend/src/components/common/loading.tsx +++ b/frontend/src/components/common/loading.tsx @@ -21,14 +21,20 @@ const styles = { }, }; -export default function Loading() { +type LoadingProps = { + message?: string; +}; + +export default function Loading({ + message = "Summoning data at lightning speed...", +}: LoadingProps) { return ( - Summoning data at lightning speed... + {message} diff --git a/frontend/src/pages/leaderboard/Leaderboard.test.tsx b/frontend/src/pages/leaderboard/Leaderboard.test.tsx index 1540c977..026cff12 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.test.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.test.tsx @@ -1,22 +1,47 @@ import { render, screen, fireEvent, within } from "@testing-library/react"; -import { vi, expect, it, describe } from "vitest"; +import { vi, expect, it, describe, beforeEach } from "vitest"; import Leaderboard from "./Leaderboard"; import * as apiHook from "../../lib/hooks/useApi"; import { renderWithRouter } from "../../tests/test-utils"; +// --- Mocks --- vi.mock("../../lib/hooks/useApi", () => ({ fetcherApiCallback: vi.fn(), })); +// Mutable auth state for mocking useAuthStore per test +type AuthState = { + me: null | { authenticated: boolean; user?: { identity?: string } }; +}; +let currentAuth: AuthState = { me: null }; + +vi.mock("../../lib/store/authStore", () => { + return { + // Simulate Zustand's selector pattern + useAuthStore: (selector: any) => + selector({ + me: currentAuth.me, + }), + }; +}); + +// --- Shared fixtures --- const mockDeadline = "2025-06-29T17:00:00-07:00"; -const mockDescription = "Implement a 2Dthe given specifications"; +const mockDescription = "Implement a 2D the given specifications"; const mockReference = "import torch"; const mockName = "test-game"; describe("Leaderboard", () => { const mockCall = vi.fn(); - it("renders name, description, gpu types and rankings", async () => { - // setup + + beforeEach(() => { + vi.clearAllMocks(); + currentAuth = { me: null }; // default: not authed + }); + + // -------------------- Basic rendering -------------------- + + it("renders name, description, gpu types; rankings visible on Rankings tab; reference visible after switching to Reference tab", () => { const mockData = { deadline: mockDeadline, description: mockDescription, @@ -27,281 +52,388 @@ describe("Leaderboard", () => { T1: [ { file_name: "test.py", - prev_score: 0.14689123399999993, + prev_score: 0.1, rank: 1, - score: 3.250463735, + score: 3.25, user_name: "user1", }, ], T2: [ { file_name: "test2.py", - prev_score: 0.14689123399999993, + prev_score: 0.1, rank: 1, - score: 3.250463735, + score: 3.25, user_name: "user2", }, ], }, }; - const mockHookReturn = { + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ data: mockData, loading: false, error: null, errorStatus: null, call: mockCall, - }; - - (apiHook.fetcherApiCallback as ReturnType).mockReturnValue( - mockHookReturn, - ); + }); - // render renderWithRouter(); - // asserts + // Header + description are outside tabs expect(screen.getByText(mockName)).toBeInTheDocument(); - - expect(screen.getByText(/reference implementation/i)).toBeInTheDocument(); - expect(screen.getByText(mockReference)).toBeInTheDocument(); - - expect(screen.getByText(/description/i)).toBeInTheDocument(); + expect(screen.getByText(/Description/i)).toBeInTheDocument(); expect(screen.getByText(mockDescription)).toBeInTheDocument(); + // Tabs exist + const rankingsTab = screen.getByRole("tab", { name: /Rankings/i }); + const referenceTab = screen.getByRole("tab", { name: /Reference/i }); + const submissionTab = screen.getByRole("tab", { name: /Submission/i }); + expect(rankingsTab).toBeInTheDocument(); + expect(referenceTab).toBeInTheDocument(); + expect(submissionTab).toBeInTheDocument(); + + // Default is Rankings tab -> rankings content visible expect(screen.getByText(/user1/)).toBeInTheDocument(); expect(screen.getByText(/user2/)).toBeInTheDocument(); + + // Switch to Reference tab before asserting its content + fireEvent.click(referenceTab); + expect(screen.getByText(/Reference Implementation/i)).toBeInTheDocument(); + expect(screen.getByText(mockReference)).toBeInTheDocument(); }); it("shows loading state", () => { - const mockHookReturn = { + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ data: null, loading: true, error: null, errorStatus: null, call: mockCall, - }; - - (apiHook.fetcherApiCallback as ReturnType).mockReturnValue( - mockHookReturn, - ); + }); renderWithRouter(); expect(screen.getByText(/Summoning/i)).toBeInTheDocument(); }); it("shows error message", () => { - const mockHookReturn = { + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ data: null, loading: false, error: "Something went wrong", errorStatus: 500, call: mockCall, - }; - - (apiHook.fetcherApiCallback as ReturnType).mockReturnValue( - mockHookReturn, - ); + }); renderWithRouter(); expect(screen.getByText("Something went wrong")).toBeInTheDocument(); }); + // -------------------- Rankings empty state -------------------- + it("shows no submission message when no rankings are present", () => { - // setup const mockData = { name: "test-empty", description: "", deadline: "", gpu_types: ["T1"], - rankings: { - T1: [], - }, + rankings: {}, }; - const mockHookReturn = { + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ data: mockData, loading: false, error: null, errorStatus: null, call: mockCall, - }; + }); - (apiHook.fetcherApiCallback as ReturnType).mockReturnValue( - mockHookReturn, - ); - - // render renderWithRouter(); - - // asserts expect(screen.getByText("test-empty")).toBeInTheDocument(); - expect(screen.getByText(/no submissions/i)).toBeInTheDocument(); + // Matches component's current copy + expect(screen.getByText(/No Submission Yet/i)).toBeInTheDocument(); + expect( + screen.getByText(/Be the first to submit a solution/i), + ).toBeInTheDocument(); }); - it("does not show expand button if ranking is less than 4 items", () => { - // setup + // -------------------- Rankings expand/hide toggle -------------------- + + it("does not show expand button if ranking has less than 4 items", () => { const mockData = { - name: "test-empty", - description: " ", + name: "test-small", + description: "", deadline: "", gpu_types: ["T1"], - referece: "", rankings: { T1: [ { - file_name: "test.py", - prev_score: 0.14689123399999993, + file_name: "f.py", + prev_score: 0, rank: 1, - score: 3.250463735, - user_name: "user1", + score: 1, + user_name: "u1", }, { - file_name: "test.py", - prev_score: 0.14689123399999993, + file_name: "f.py", + prev_score: 0, rank: 2, - score: 3.250463735, - user_name: "user2", + score: 1, + user_name: "u2", }, { - file_name: "test.py", - prev_score: 0.14689123399999993, + file_name: "f.py", + prev_score: 0, rank: 3, - score: 3.250463735, - user_name: "user3", + score: 1, + user_name: "u3", }, ], }, }; - const mockHookReturn = { + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ data: mockData, loading: false, error: null, errorStatus: null, call: mockCall, - }; - - (apiHook.fetcherApiCallback as ReturnType).mockReturnValue( - mockHookReturn, - ); + }); - // render renderWithRouter(); - - // asserts expect( screen.queryByTestId("ranking-show-all-button-0"), ).not.toBeInTheDocument(); }); - it("does show expand button if ranking is more than 3 items", () => { - // setup + it("shows expand button if ranking has ≥ 4 items and toggles rows", () => { const mockData = { - name: "test-empty", - description: " ", + name: "test-large", + description: "", deadline: "", gpu_types: ["T1"], - referece: "", rankings: { T1: [ { - file_name: "test.py", - prev_score: 0.14689123399999993, + file_name: "f.py", + prev_score: 0, rank: 1, - score: 3.250463735, - user_name: "user1", + score: 1, + user_name: "u1", }, { - file_name: "test.py", - prev_score: 0.14689123399999993, + file_name: "f.py", + prev_score: 0, rank: 2, - score: 3.250463735, - user_name: "user2", + score: 1, + user_name: "u2", }, { - file_name: "test.py", - prev_score: 0.14689123399999993, + file_name: "f.py", + prev_score: 0, rank: 3, - score: 3.250463735, - user_name: "user3", + score: 1, + user_name: "u3", }, { - file_name: "test.py", - prev_score: 0.14689123399999993, + file_name: "f.py", + prev_score: 0, rank: 4, - score: 3.250463735, - user_name: "user4", + score: 1, + user_name: "u4", }, ], }, }; - const mockHookReturn = { + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ data: mockData, loading: false, error: null, errorStatus: null, call: mockCall, - }; + }); - (apiHook.fetcherApiCallback as ReturnType).mockReturnValue( - mockHookReturn, - ); - - // render renderWithRouter(); - // asserts - const button = screen.queryByTestId("ranking-show-all-button-0"); - expect(button).toBeInTheDocument(); + const btn = screen.queryByTestId("ranking-show-all-button-0"); + expect(btn).toBeInTheDocument(); + + // By default only 3 rows shown expect(screen.queryAllByTestId("ranking-0-row")).toHaveLength(3); - expect(within(button!).getByText(/Show all/i)).toBeInTheDocument(); - // click button - fireEvent.click(button!); - expect(within(button!).getByText(/Hide/i)).toBeInTheDocument(); + // Click to show all + fireEvent.click(btn!); expect(screen.queryAllByTestId("ranking-0-row")).toHaveLength(4); + expect(within(btn!).getByText(/Hide/i)).toBeInTheDocument(); + + // Click to hide again + fireEvent.click(btn!); + expect(screen.queryAllByTestId("ranking-0-row")).toHaveLength(3); }); - it("toggles expanded state for codeblock on click", () => { - // setup + // -------------------- Reference codeblock toggle -------------------- + + it("toggles expanded state for reference codeblock (after switching to Reference tab)", () => { const mockData = { - name: "test-empty", - description: " ", + name: "test-code", + description: "", deadline: "", gpu_types: ["T1"], - referece: "", - rankings: { - T1: [], - }, + reference: mockReference, + rankings: { T1: [] }, }; - const mockHookReturn = { + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ data: mockData, loading: false, error: null, errorStatus: null, call: mockCall, - }; + }); - (apiHook.fetcherApiCallback as ReturnType).mockReturnValue( - mockHookReturn, - ); - - // render renderWithRouter(); - // asserts + // Must switch to Reference tab first + fireEvent.click(screen.getByRole("tab", { name: /Reference/i })); + const toggle = screen.getByTestId("codeblock-show-all-toggle"); expect(within(toggle).getByText(/show more/i)).toBeInTheDocument(); - // Click to expand fireEvent.click(toggle); expect(within(toggle).getByText(/hide/i)).toBeInTheDocument(); - // Click to collapse again fireEvent.click(toggle); expect(within(toggle).getByText(/show more/i)).toBeInTheDocument(); }); + + // -------------------- Tabs behavior (switching) -------------------- + + it("starts on Rankings tab by default and can switch to Reference and back", () => { + const mockData = { + deadline: mockDeadline, + description: mockDescription, + name: mockName, + reference: mockReference, + gpu_types: ["T1"], + rankings: { + T1: [ + { + file_name: "test.py", + prev_score: 0.1, + rank: 1, + score: 3.25, + user_name: "user1", + }, + ], + }, + }; + + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ + data: mockData, + loading: false, + error: null, + errorStatus: null, + call: mockCall, + }); + + renderWithRouter(); + + // Default selected tab should be Rankings (content visible) + expect(screen.getByText(/user1/)).toBeInTheDocument(); + + // Switch to Reference tab + fireEvent.click(screen.getByRole("tab", { name: /Reference/i })); + expect(screen.getByText(/Reference Implementation/i)).toBeInTheDocument(); + expect(screen.getByText(mockReference)).toBeInTheDocument(); + + // Switch back to Rankings tab + fireEvent.click(screen.getByRole("tab", { name: /Rankings/i })); + expect(screen.getByText(/user1/)).toBeInTheDocument(); + }); + + it("can switch from Rankings to Submission tab when not authed", () => { + const mockData = { + deadline: mockDeadline, + description: mockDescription, + name: mockName, + reference: mockReference, + gpu_types: ["T1"], + rankings: { T1: [] }, + }; + + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ + data: mockData, + loading: false, + error: null, + errorStatus: null, + call: mockCall, + }); + + renderWithRouter(); + + fireEvent.click(screen.getByRole("tab", { name: /Submission/i })); + expect(screen.getByText(/please login to submit/i)).toBeInTheDocument(); + }); + + // -------------------- Submission tab: authed vs not authed -------------------- + + it("Submission tab shows login tip when not authed", () => { + const mockData = { + name: "lb-noauth", + description: "", + deadline: "", + gpu_types: ["T1"], + reference: mockReference, + rankings: { T1: [] }, + }; + + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ + data: mockData, + loading: false, + error: null, + errorStatus: null, + call: mockCall, + }); + + // No need to rely on URL for this test; just render and click the tab + renderWithRouter(); + + // Switch to the Submission tab explicitly + fireEvent.click(screen.getByRole("tab", { name: /Submission/i })); + + // Now the Submission panel should be visible for non-authed users + expect(screen.getByText(/please login to submit/i)).toBeInTheDocument(); + }); + + it("Submission tab renders submit UI when authed (URL drives tab)", () => { + currentAuth = { me: { authenticated: true, user: { identity: "u-1" } } }; + + const mockData = { + name: "lb-auth", + description: "", + deadline: "", + gpu_types: ["T1"], + reference: mockReference, + rankings: { T1: [] }, + }; + + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ + data: mockData, + loading: false, + error: null, + errorStatus: null, + call: mockCall, + }); + + renderWithRouter(); + + // Switch to the Submission tab explicitly + fireEvent.click(screen.getByRole("tab", { name: /Submission/i })); + // Login tip should NOT be visible; submission card should be visible + expect( + screen.queryByText(/please login to submit/i), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("leaderboard-submit-btn")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index 5f06deaa..e0d287a9 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -22,8 +22,8 @@ import Loading from "../../components/common/loading"; import { ConstrainedContainer } from "../../components/app-layout/ConstrainedContainer"; import { SubmissionMode } from "../../lib/types/mode"; import { useAuthStore } from "../../lib/store/authStore"; -import ListSubmissionSidePanel from "./components/ListSubmissionsSidePanel"; -import LeaderboardSubmitDialog from "./components/LeaderboardSubmitDialog"; +import ListSubmissionSidePanel from "./components/submission-history/ListSubmissionsSidePanel"; +import LeaderboardSubmit from "./components/LeaderboardSubmit"; export const CardTitle = styled(Typography)(() => ({ fontSize: "1.5rem", fontWeight: "bold", @@ -153,7 +153,7 @@ export default function Leaderboard() { {/* Ranking Tab */} - {data.rankings.length > 0 ? ( + {Object.entries(data.rankings).length > 0 ? ( ) : ( @@ -185,35 +185,22 @@ export default function Leaderboard() { {!isAuthed ? (
please login to submit
) : ( - <> - - - Submission - - - - - - - - - - - + + Submission + + + + + )}
diff --git a/frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx new file mode 100644 index 00000000..629f0e48 --- /dev/null +++ b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx @@ -0,0 +1,251 @@ +import { + render, + screen, + within, + waitForElementToBeRemoved, + cleanup, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, afterEach } from "vitest"; +import LeaderboardSubmit from "./LeaderboardSubmit"; + +// --- Mocks --- +vi.mock("../../../api/api", () => ({ + submitFile: vi.fn(), +})); + +// Make AlertBar deterministic and closable +vi.mock("../../../components/alert/AlertBar", () => ({ + __esModule: true, + default: ({ notice, onClose }: any) => + notice?.open ? ( +
+
{notice.title}
+
{notice.message}
+ +
+ ) : null, +})); + +// Minimal loader mock (inside submit button) +vi.mock("../../../components/common/LoadingCircleProgress", () => ({ + __esModule: true, + default: ({ message }: { message?: string }) => ( + {message ?? "loading"} + ), +})); + +// Grab mocked submitFile as a vi.Mock +import { submitFile } from "../../../api/api"; +import { act } from "react"; +const submitFileMock = submitFile as unknown as vi.Mock; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +// --- Test helpers --- +function createFile({ + name, + sizeMB = 1, + type = "text/x-python", +}: { + name: string; + sizeMB?: number; + type?: string; +}) { + const bytes = sizeMB * 1024 * 1024; + return new File([new Uint8Array(bytes)], name, { type }); +} + +async function selectMUIOption(label: string | RegExp, optionText: string) { + const trigger = screen.getByLabelText(label); + await userEvent.click(trigger); + const listbox = await screen.findByRole("listbox"); + await userEvent.click(within(listbox).getByText(optionText)); +} + +function getHiddenFileInput(): HTMLInputElement { + const el = screen.getByTestId( + "submission-dialog-file-input", + ) as HTMLInputElement; + if (!el) throw new Error("file input not found"); + return el; +} + +async function formDataToObject(fd: FormData) { + const out: Record = {}; + fd.forEach((v, k) => (out[k] = v)); + return out; +} + +// --- Shared props --- +const baseProps = { + leaderboardId: "42", + leaderboardName: "LB", + gpuTypes: ["A100", "H100"], + modes: ["leaderboard", "test"], +}; + +afterEach(() => { + vi.clearAllMocks(); + cleanup(); +}); + +describe("LeaderboardSubmit (Vitest)", () => { + it("renders trigger and opens/closes dialog (waits for close)", async () => { + render(); + const trigger = screen.getByTestId("leaderboard-submit-btn"); + expect(trigger).toBeInTheDocument(); + + await userEvent.click(trigger); + const title = await screen.findByText(/Submit to Leaderboard/i); + expect(title).toBeInTheDocument(); + + // Click Cancel and wait for Dialog to unmount (MUI transition/portal) + await userEvent.click(screen.getByRole("button", { name: /cancel/i })); + await waitForElementToBeRemoved(() => + screen.queryByText(/Submit to Leaderboard/i), + ); + }); + + it("validates file extension and size", async () => { + render(); + await userEvent.click(screen.getByTestId("leaderboard-submit-btn")); + + const input = getHiddenFileInput(); + + // too large .py -> shows size error + await userEvent.upload(input, createFile({ name: "big.py", sizeMB: 6 })); + expect( + await screen.findByTestId("submission-dialog-error-alert"), + ).toHaveTextContent(/File too large \(> 5 MB\)/i); + + // valid .py -> error disappears, filename shown + await userEvent.upload(input, createFile({ name: "algo.py", sizeMB: 1 })); + // the alert should be gone + expect(screen.getByTestId("submission-dialog-file-name")).toHaveTextContent( + /algo\.py/i, + ); + }); + + it("changes GPU type and Mode via Selects", async () => { + render(); + await userEvent.click(screen.getByTestId("leaderboard-submit-btn")); + + // Defaults are first options + expect(screen.getByLabelText(/GPU Type/i)).toHaveTextContent("A100"); + expect(screen.getByLabelText(/Mode/i)).toHaveTextContent("leaderboard"); + + await selectMUIOption(/GPU Type/i, "H100"); + await selectMUIOption(/Mode/i, "test"); + + expect(screen.getByLabelText(/GPU Type/i)).toHaveTextContent("H100"); + expect(screen.getByLabelText(/Mode/i)).toHaveTextContent("test"); + }); + + it("disabled until file chosen; submits -> loading -> success; AlertBar can be closed", async () => { + // Keep the promise pending until we assert the loader is visible + const d = deferred<{ message: string }>(); + submitFileMock.mockImplementation(() => d.promise); + + render(); + await userEvent.click(screen.getByTestId("leaderboard-submit-btn")); + + const submitBtn = screen.getByRole("button", { name: /^Submit$/i }); + expect(submitBtn).toBeDisabled(); + + await userEvent.upload( + getHiddenFileInput(), + createFile({ name: "algo.py", sizeMB: 1 }), + ); + expect(submitBtn).not.toBeDisabled(); + + await userEvent.click(submitBtn); + + // Loader should be visible while promise is pending + expect(await screen.findByTestId("loading-circle")).toHaveTextContent( + /submitting/i, + ); + expect(submitBtn).toBeDisabled(); + + // Verify FormData was sent + expect(submitFileMock).toHaveBeenCalledTimes(1); + const fd = await formDataToObject( + submitFileMock.mock.calls[0][0] as FormData, + ); + expect(fd["leaderboard_id"]).toBe("42"); + expect(fd["leaderboard"]).toBe("LB"); + expect(fd["gpu_type"]).toBe("A100"); + expect(fd["submission_mode"]).toBe("leaderboard"); + expect(fd["file"]).toBeInstanceOf(File); + expect((fd["file"] as File).name).toBe("algo.py"); + + // Now resolve the API and wait for success UI + await act(async () => { + d.resolve({ message: "Submitted successfully." }); + }); + + const alert = await screen.findByTestId("alertbar"); + expect(within(alert).getByTestId("alertbar-title")).toHaveTextContent( + /Submission is accepted/i, + ); + + // Close the alert bar (your “can close anytime” requirement) + await userEvent.click(within(alert).getByTestId("alertbar-close")); + expect(screen.queryByTestId("alertbar")).not.toBeInTheDocument(); + }); + + it("shows error alert when submitFile rejects; submit becomes enabled again", async () => { + submitFileMock.mockRejectedValue(new Error("Boom")); + + render(); + await userEvent.click(screen.getByTestId("leaderboard-submit-btn")); + + await userEvent.upload( + getHiddenFileInput(), + createFile({ name: "algo.py", sizeMB: 1 }), + ); + const submitBtn = screen.getByRole("button", { name: /^Submit$/i }); + expect(submitBtn).not.toBeDisabled(); + + await userEvent.click(submitBtn); + + // Error alert appears with message (either default or thrown) + const err = await screen.findByTestId("submission-dialog-error-alert"); + expect(err).toHaveTextContent(/Submission failed|Boom/i); + + // Button should be enabled again after failure + expect(submitBtn).not.toBeDisabled(); + }); + + it("Cancel resets file state (waits for close and reopen)", async () => { + render(); + + // Open and attach a file + await userEvent.click(screen.getByTestId("leaderboard-submit-btn")); + await userEvent.upload( + getHiddenFileInput(), + createFile({ name: "algo.py", sizeMB: 1 }), + ); + expect(screen.getByTestId("submission-dialog-file-name")).toHaveTextContent( + /algo\.py/i, + ); + + // Cancel and wait for dialog removal + await userEvent.click(screen.getByRole("button", { name: /cancel/i })); + await waitForElementToBeRemoved(() => + screen.queryByText(/Submit to Leaderboard/i), + ); + + // Reopen: filename should be cleared + await userEvent.click(screen.getByTestId("leaderboard-submit-btn")); + expect( + screen.getByTestId("submission-dialog-file-name"), + ).not.toHaveTextContent(/algo\.py/i); + }); +}); diff --git a/frontend/src/pages/leaderboard/components/LeaderboardSubmitDialog.tsx b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx similarity index 74% rename from frontend/src/pages/leaderboard/components/LeaderboardSubmitDialog.tsx rename to frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx index 6ddf2b91..c0052617 100644 --- a/frontend/src/pages/leaderboard/components/LeaderboardSubmitDialog.tsx +++ b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import Button from "@mui/material/Button"; import Dialog from "@mui/material/Dialog"; import DialogTitle from "@mui/material/DialogTitle"; @@ -7,21 +7,41 @@ import DialogActions from "@mui/material/DialogActions"; import DialogContentText from "@mui/material/DialogContentText"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; -import TextField from "@mui/material/TextField"; import FormControl from "@mui/material/FormControl"; import InputLabel from "@mui/material/InputLabel"; import Select from "@mui/material/Select"; import MenuItem from "@mui/material/MenuItem"; import Alert from "@mui/material/Alert"; -import CircularProgress from "@mui/material/CircularProgress"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import { submitFile } from "../../../api/api"; +import LoadingCircleProgress from "../../../components/common/LoadingCircleProgress"; +import AlertBar from "../../../components/alert/AlertBar"; + +const styles = { + triggerBtn: { borderRadius: 2, textTransform: "none" }, + title: { fontWeight: 700 }, + hint: { mt: 0.5, mb: 2 }, + stack: { mt: 1 }, + fileBtn: { textTransform: "none", mb: 1 }, + fileText: { "& .MuiInputBase-input": { cursor: "default" } }, + actions: { px: 3, pb: 2 }, + submitBtn: { + borderRadius: 3, + px: 3, + py: 1, + fontWeight: "bold", + textTransform: "none", + background: "linear-gradient(90deg, #a5b4fc 0%, #93c5fd 100%)", + boxShadow: "0 4px 10px rgba(0,0,0,0.15)", + transition: "all 0.2s ease-in-out", + }, +} as const; /** - * Subcomponent: LeaderboardSubmitDialog (MUI concise version) + * Subcomponent: LeaderboardSubmit * Parent provides only: leaderboardId, leaderboardName, gpuTypes, modes */ -export default function LeaderboardSubmitDialog({ +export default function LeaderboardSubmit({ leaderboardId, leaderboardName, gpuTypes, @@ -43,6 +63,7 @@ export default function LeaderboardSubmitDialog({ | { kind: "ok"; msg: string } >({ kind: "idle" }); + const [sucessAlert, setSuccessAlert] = useState(false); const fileInputRef = useRef(null); const canSubmit = useMemo( @@ -57,6 +78,7 @@ export default function LeaderboardSubmitDialog({ } function validatePythonFile(f: File): string | null { + // set a max file size too const MAX_MB = 5; const name = f.name.toLowerCase(); if (!name.endsWith(".py")) return "Please select a .py file."; @@ -80,7 +102,6 @@ export default function LeaderboardSubmitDialog({ async function handleSubmit() { if (!canSubmit || !file) return; setStatus({ kind: "uploading" }); - try { const form = new FormData(); form.set("leaderboard_id", String(leaderboardId)); @@ -99,22 +120,49 @@ export default function LeaderboardSubmitDialog({ setTimeout(() => { setOpen(false); resetForm(); - }, 600); + }, 100); } catch (e: any) { setStatus({ kind: "error", msg: e?.message || "Submission failed" }); } } + + useEffect(() => { + if (status.kind === "ok") { + setSuccessAlert(true); + } + }, [status]); + + const renderSubmitButton = () => ( + + ); return ( <> - + { + setSuccessAlert(false); + }} + /> + {renderSubmitButton()} { @@ -129,7 +177,6 @@ export default function LeaderboardSubmitDialog({ Choose a .py file and set GPU type & mode. - GPU Type @@ -146,7 +193,6 @@ export default function LeaderboardSubmitDialog({ ))} - Mode -
{file?.name ?? ""}
+
+ {file?.name ?? ""} +
{status.kind === "error" && ( - - {status.msg} - - )} - {status.kind === "ok" && ( - + {status.msg} )}
- diff --git a/frontend/src/pages/leaderboard/components/submission-history/ListSubmissionsSidePanel.tsx b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx similarity index 97% rename from frontend/src/pages/leaderboard/components/submission-history/ListSubmissionsSidePanel.tsx rename to frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx index 41606da4..9faa2fca 100644 --- a/frontend/src/pages/leaderboard/components/submission-history/ListSubmissionsSidePanel.tsx +++ b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx @@ -62,7 +62,7 @@ const styles = { }, }; -export default function ListSubmissionSidePanel({ +export default function SubmissionHistorySection({ leaderboardId, userId, pageSize = 10, @@ -101,7 +101,7 @@ export default function ListSubmissionSidePanel({ }, [page, pageSize, total]); return ( - + {/* Header */} Your submission history diff --git a/kernelboard/api/news.py b/kernelboard/api/news.py index b3cbc37a..e65b9ce2 100644 --- a/kernelboard/api/news.py +++ b/kernelboard/api/news.py @@ -18,7 +18,6 @@ def list_news_items(): try: news_dir = os.path.join(current_app.root_path, "static/news") news_contents = [] - logger.info("") for filename in os.listdir(news_dir): if filename.endswith(".md"): target_file = os.path.join(news_dir, filename) diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index 0197c183..110f14e8 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -44,7 +44,7 @@ def submission(): user_id, username = get_id_and_username_from_session() web_token = get_user_token(user_id) if not web_token: - logger.error(f"user %s missing web token", user_id) + logger.error("user %s missing web token", user_id) return http_error( message="cannot find user info from db for user %s, if this is a bug, please contact the gpumode administrator" % username, @@ -97,7 +97,7 @@ def submission(): WEB_AUTH_HEADER: web_token, } - logger.info(f"send submission request to leaderboard") + logger.info("send submission request to leaderboard") try: resp = requests.post(url, headers=headers, files=files, timeout=180) except requests.RequestException as e: diff --git a/kernelboard/lib/logging.py b/kernelboard/lib/logging.py index 86a3ee45..a631e0e2 100644 --- a/kernelboard/lib/logging.py +++ b/kernelboard/lib/logging.py @@ -15,6 +15,8 @@ def configure_logging(app): # add handler to app.logger app.logger.setLevel(logging.INFO) app.logger.addHandler(handler) + app.logger.propagate = False + # set root logger logging.basicConfig(level=logging.INFO, handlers=[handler]) diff --git a/kernelboard/lib/time.py b/kernelboard/lib/time.py index 34761dd0..8e3944ba 100644 --- a/kernelboard/lib/time.py +++ b/kernelboard/lib/time.py @@ -29,14 +29,6 @@ def _to_time_left(deadline: str | datetime, now: datetime) -> str | None: hour_label = "hour" if hours == 1 else "hours" return f"{days} {day_label} {hours} {hour_label} remaining" -def is_ended(deadline: str | datetime, now: datetime) -> bool: - """ - Check if deadline has passed. - - Returns: True if deadline has passed, otherwise False. - """ - return _to_time_left(deadline, now) == "ended" - def format_datetime(dt: datetime | str) -> str: """ Common formatting for datetime objects. diff --git a/tests/api/test_submission_api.py b/tests/api/test_submission_api.py index 68b48fee..ce330f70 100644 --- a/tests/api/test_submission_api.py +++ b/tests/api/test_submission_api.py @@ -2,30 +2,17 @@ import http from io import BytesIO from types import SimpleNamespace -from typing import Optional, TypedDict from unittest.mock import patch, MagicMock import flask_login -import os import datetime as dt -import http -import datetime as dt -from io import BytesIO -from unittest.mock import MagicMock, patch import requests import pytest from kernelboard.lib.db import get_db_connection from psycopg2.extras import execute_values -import requests _TEST_USER_ID = "333" _TEST_WEB_AUTH_ID = "111" -import datetime as dt -import pytest -from psycopg2.extras import execute_values # 确保已导入 -import datetime as dt -import pytest -from psycopg2.extras import execute_values # 确保已导入 def _delete_user_graph(conn, user_id: str) -> None: """Idempotent cleanup: remove all rows under a user in FK-safe order.""" From a04b78778b08f9bbf7a515a1b0088965a5b1bfd9 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 26 Aug 2025 00:29:48 -0700 Subject: [PATCH 07/17] echo variables --- kernelboard/api/auth.py | 2 + kernelboard/api/submission.py | 209 +++++++++++++++++++--------------- kernelboard/lib/auth_utils.py | 82 +++++-------- kernelboard/lib/db.py | 16 ++- 4 files changed, 161 insertions(+), 148 deletions(-) diff --git a/kernelboard/api/auth.py b/kernelboard/api/auth.py index 96dcf8ef..47b400c4 100644 --- a/kernelboard/api/auth.py +++ b/kernelboard/api/auth.py @@ -16,6 +16,7 @@ from flask_login import UserMixin, current_user, login_user, logout_user from kernelboard.lib.auth_utils import ensure_user_info_with_token, get_user_info_from_session +from kernelboard.lib.db import get_db_connection from kernelboard.lib.status_code import http_success auth_bp = Blueprint("auth", __name__) @@ -135,6 +136,7 @@ def callback(provider: str): if not current_user.is_anonymous: return redirect("/kb/") + provider_data = app.config["OAUTH2_PROVIDERS"].get(provider) if not provider_data: return redirect("/kb/login?error=invalid_provider") diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index 110f14e8..152b84f2 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -1,10 +1,10 @@ import http -from typing import Any, List, Tuple +from typing import Any, List, Optional, Tuple from flask import Blueprint, request, jsonify +import psycopg2 import requests from kernelboard.lib.auth_utils import ( get_id_and_username_from_session, - get_user_token, is_auth, ) from kernelboard.lib.db import get_db_connection @@ -12,6 +12,7 @@ from kernelboard.lib.file_handler import get_submission_file_info from kernelboard.lib.status_code import http_error, http_success import logging +import datetime as dt import os logger = logging.getLogger(__name__) @@ -25,6 +26,9 @@ "submission_mode", ] +RATE_LIMIT = 5 +WINDOW = dt.timedelta(minutes=30) + # official one: https://discord-cluster-manager-1f6c4782e60a.herokuapp.com/submission WEB_AUTH_HEADER = "X-Web-Auth-Id" MAX_CONTENT_LENGTH = 20 * 1024 * 1024 # 20MB max file size @@ -38,39 +42,25 @@ def submission(): logger.error("user did not login") return http_error( message="cannnot get user id, please log in first, if this is unexpected, please contact the gpumode administrator", - code=10000 + http.HTTPStatus.UNAUTHORIZED.value, status_code=http.HTTPStatus.UNAUTHORIZED, ) user_id, username = get_id_and_username_from_session() + web_token = get_user_token(user_id) if not web_token: logger.error("user %s missing web token", user_id) return http_error( - message="cannot find user info from db for user %s, if this is a bug, please contact the gpumode administrator" - % username, - code=10000 + http.HTTPStatus.INTERNAL_SERVER_ERROR.value, - status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, + message="cannot find user info from db for user. if this is a bug, please contact the gpumode administrator", status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, ) req = request.form.to_dict() try: validate_required_fields(req, REQUIRED_SUBMISSION_REQUEST_FIELDS) - except ValidationError as e: - logger.error(f"Invalid submission request: {e}") - return http_error( - message=e.message, - code=e.code, - status_code=e.status, - **e.extras, - ) - - try: filename, mime, f = get_submission_file_info(request) except ValidationError as e: - logger.error(f"Invalid file from submission request: {e}") + logger.error(f"Invalid submission request: {e}") return http_error( message=e.message, - code=e.code, status_code=e.status, **e.extras, ) @@ -78,12 +68,12 @@ def submission(): logger.error(f"Failed to get submission file info: {e}") return http_error( message=str(e), - code=10000 + http.HTTPStatus.INTERNAL_SERVER_ERROR.value, status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, ) + logger.info("prepare sending submission request") - # form post request to external api + # form request to cluster-management api gpu_type = request.form.get("gpu_type") submission_mode = request.form.get("submission_mode") leaderboard_name = request.form.get("leaderboard") @@ -117,21 +107,13 @@ def submission(): else: return http_error( message=message, - code=10000 + resp.status_code, status_code=http.HTTPStatus(resp.status_code), data=payload, ) - except ValueError as e: - return http_error( - message=f"submission failed due to: {e}", - code=10000 + http.HTTPStatus.BAD_REQUEST.value, - status_code=http.HTTPStatus.BAD_REQUEST, - ) except Exception as e: - logger.error(f"unexpected error happened: {e}") + logger.error(f"faild to submit request: {e}") return http_error( - message=f"unexpected error happened: {e}", - code=10000 + http.HTTPStatus.INTERNAL_SERVER_ERROR.value, + message=f"{e}", status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, ) @@ -140,6 +122,7 @@ def submission(): def list_submissions(): """ GET /submissions?leaderboard_id=123&&limit=20&offset=0 + limit & offset are used for pagination """ # TODO(elainewy): currently we only fetch the user's all submissions, but we do not have details of: # submit method: discord-bot vs cli vs web @@ -193,6 +176,16 @@ def list_submissions(): ) +def get_cluster_manager_endpoint(): + """ + Return OAuth2 provider information. + """ + env_var = os.getenv("DISCORD_CLUSTER_MANAGER_API_BASE_URL", "") + if not env_var: + logger.warning("DISCORD_CLUSTER_MANAGER_API_BASE_URL is not set!!!") + return env_var + + def list_user_submissions_with_status( leaderboard_id: int, user_id: int, @@ -200,66 +193,98 @@ def list_user_submissions_with_status( offset: int = 0, ) -> Tuple[List[dict[str, Any]], int]: conn = get_db_connection() - try: - with conn: - with conn.cursor() as cur: - cur.execute( - """ - SELECT - s.id AS submission_id, - s.leaderboard_id, - s.file_name, - s.submission_time AS submitted_at, - s.done AS submissoin_done, - j.status, - j.error, - j.last_heartbeat, - j.created_at AS job_created_at - FROM leaderboard.submission AS s - LEFT JOIN leaderboard.submission_job_status AS j - ON j.submission_id = s.id - WHERE s.leaderboard_id = %s - AND s.user_id = %s - ORDER BY s.submission_time DESC - LIMIT %s OFFSET %s - """, - (leaderboard_id, user_id, limit, offset), - ) - rows = cur.fetchall() - items = [ - { - "submission_id": r[0], - "leaderboard_id": r[1], - "file_name": r[2], - "submitted_at": r[3], - "submission_done": r[4], - "status": r[5], - "error": r[6], - "last_heartbeat": r[7], - "job_created_at": r[8], - } - for r in rows - ] - cur.execute( - """ - SELECT COUNT(*) AS total - FROM leaderboard.submission AS s - WHERE s.leaderboard_id = %s - AND s.user_id = %s - """, - (leaderboard_id, user_id), - ) - total = cur.fetchone()[0] - return items, total - finally: - conn.close() + with conn.cursor() as cur: + cur.execute( + """ + SELECT + s.id AS submission_id, + s.leaderboard_id, + s.file_name, + s.submission_time AS submitted_at, + s.done AS submissoin_done, + j.status, + j.error, + j.last_heartbeat, + j.created_at AS job_created_at + FROM leaderboard.submission AS s + LEFT JOIN leaderboard.submission_job_status AS j + ON j.submission_id = s.id + WHERE s.leaderboard_id = %s + AND s.user_id = %s + ORDER BY s.submission_time DESC + LIMIT %s OFFSET %s + """, + (leaderboard_id, user_id, limit, offset), + ) + rows = cur.fetchall() + items = [ + { + "submission_id": r[0], + "leaderboard_id": r[1], + "file_name": r[2], + "submitted_at": r[3], + "submission_done": r[4], + "status": r[5], + "error": r[6], + "last_heartbeat": r[7], + "job_created_at": r[8], + } + for r in rows + ] + cur.execute( + """ + SELECT COUNT(*) AS total + FROM leaderboard.submission AS s + WHERE s.leaderboard_id = %s + AND s.user_id = %s + """, + (leaderboard_id, user_id), + ) + row = cur.fetchone() + if row is None: + return [], 0 + (total,) = row + return items, total -def get_cluster_manager_endpoint(): - """ - Return OAuth2 provider information. - """ - env_var = os.getenv("DISCORD_CLUSTER_MANAGER_API_BASE_URL", "") - if not env_var: - logger.warning("DISCORD_CLUSTER_MANAGER_API_BASE_URL is not set!!!") - return env_var +def is_rate_limited(user_id: int, window: dt.timedelta = WINDOW) -> bool: + now = dt.datetime.now(dt.timezone.utc) + cutoff = now - window + + conn = get_db_connection() + with conn.cursor() as cur: + cur.execute( + """ + SELECT COUNT(*) + FROM leaderboard.submission + WHERE user_id = %s + AND submission_time >= %s + """, + (user_id, cutoff), + ) + (cnt,) = cur.fetchone() or (0,) + limited = cnt >= RATE_LIMIT + if limited: + logger.warning( + "User %s hit rate limit: %s submissions in last %s minutes", + user_id, + cnt, + int(window.total_seconds() // 60), + ) + return limited + + +def get_user_token(user_id: int) -> Optional[str]: + conn = get_db_connection() + with conn.cursor() as cur: + cur.execute( + """ + SELECT web_auth_id + FROM leaderboard.user_info + WHERE id = %s + """, + (user_id,), + ) + row = cur.fetchone() + # row will be a tuple like (token,) or None + return row[0] if row else None diff --git a/kernelboard/lib/auth_utils.py b/kernelboard/lib/auth_utils.py index b9d3ba11..575201e3 100644 --- a/kernelboard/lib/auth_utils.py +++ b/kernelboard/lib/auth_utils.py @@ -6,7 +6,8 @@ from flask_login import current_user from kernelboard.lib.db import get_db_connection - +import logging +logger = logging.getLogger(__name__) def get_provider_and_identity(user_id: Optional[str])-> Any: provider = identity = None @@ -60,57 +61,32 @@ def ensure_user_info_with_token(user_id: int, user_name: str) -> Optional[Any]: """ new_token = secrets.token_hex(16) conn = get_db_connection() - try: - with conn: # automatically commit on success / rollback on error - with conn.cursor() as cur: - # Attempt "insert or update only if web_auth_id is NULL" - cur.execute( - """ - INSERT INTO leaderboard.user_info (id, user_name, web_auth_id) - VALUES (%s, %s, %s) - ON CONFLICT (id) DO UPDATE - SET web_auth_id = EXCLUDED.web_auth_id - WHERE leaderboard.user_info.web_auth_id IS NULL - RETURNING id, user_name, web_auth_id - """, - (user_id, user_name, new_token), - ) - row = cur.fetchone() - - # row exists if inserted new row OR updated an existing row with NULL token - if row: - return row - - # if no upsert was done, fetch the existing row and return it - cur.execute( - """ - SELECT id, user_name, web_auth_id - FROM leaderboard.user_info - WHERE id = %s - """, - (user_id,), - ) - return cur.fetchone() - finally: - conn.close() - -def get_user_token(user_id: int) -> Optional[str]: - conn = get_db_connection() - try: - with conn: # auto commit / rollback - with conn.cursor() as cur: - cur.execute( - """ - SELECT web_auth_id - FROM leaderboard.user_info - WHERE id = %s - """, - (user_id,), - ) - row = cur.fetchone() - # row will be a tuple like (token,) or None - return row[0] if row else None - finally: - conn.close() + with conn.cursor() as cur: + # Attempt "insert or update only if web_auth_id is NULL" + cur.execute( + """ + INSERT INTO leaderboard.user_info (id, user_name, web_auth_id) + VALUES (%s, %s, %s) + ON CONFLICT (id) DO UPDATE + SET web_auth_id = EXCLUDED.web_auth_id + WHERE leaderboard.user_info.web_auth_id IS NULL + RETURNING id, user_name, web_auth_id + """, + (user_id, user_name, new_token), + ) + row = cur.fetchone() + # row exists if inserted new row OR updated an existing row with NULL token + if row: + return row + # if no upsert was done, fetch the existing row and return it + cur.execute( + """ + SELECT id, user_name, web_auth_id + FROM leaderboard.user_info + WHERE id = %s + """, + (user_id,), + ) + return cur.fetchone() diff --git a/kernelboard/lib/db.py b/kernelboard/lib/db.py index 0cce2f43..deb09215 100644 --- a/kernelboard/lib/db.py +++ b/kernelboard/lib/db.py @@ -1,7 +1,7 @@ import psycopg2 from flask import g, Flask, current_app - - +import logging +logger = logging.getLogger(__name__) def get_db_connection() -> psycopg2.extensions.connection: """ Get a database connection from the `g` object. If the connection is not @@ -24,8 +24,18 @@ def close_db_connection(e=None): """ db = g.pop("db_connection", None) if db is not None: - db.close() + try: + if e is not None: + logger.info("Rolling back database connection") + db.rollback() + else: + logger.info("Committing database connection") + db.commit() + finally: + db.close() + logger.info("Database connection closed") def init_app(app: Flask): + # close the database connection when the application context is destroyed (e.g. at the end of a api request) app.teardown_appcontext(close_db_connection) From d23563ac0c0d434b0467bd65d30c29d52485567f Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 26 Aug 2025 01:52:13 -0700 Subject: [PATCH 08/17] echo variables --- kernelboard/api/submission.py | 18 ++++++++++++++---- kernelboard/lib/db.py | 3 --- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index 152b84f2..6c843706 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -3,6 +3,7 @@ from flask import Blueprint, request, jsonify import psycopg2 import requests +from werkzeug.exceptions import TooManyRequests from kernelboard.lib.auth_utils import ( get_id_and_username_from_session, is_auth, @@ -26,8 +27,8 @@ "submission_mode", ] -RATE_LIMIT = 5 -WINDOW = dt.timedelta(minutes=30) +RATE_LIMIT = 10 +WINDOW = dt.timedelta(minutes=60) # official one: https://discord-cluster-manager-1f6c4782e60a.herokuapp.com/submission WEB_AUTH_HEADER = "X-Web-Auth-Id" @@ -50,10 +51,18 @@ def submission(): if not web_token: logger.error("user %s missing web token", user_id) return http_error( - message="cannot find user info from db for user. if this is a bug, please contact the gpumode administrator", status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, + message="cannot find user info from db for user. if this is a bug, please contact the gpumode administrator", + status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, ) req = request.form.to_dict() + rate_limited = is_rate_limited(user_id=user_id, window=WINDOW) + if rate_limited: + return http_error( + message=f"Execeeding rate limit: {RATE_LIMIT} submission per {WINDOW.seconds // 60} minutes, please try again later", + status_code=http.HTTPStatus.TOO_MANY_REQUESTS, + ) + try: validate_required_fields(req, REQUIRED_SUBMISSION_REQUEST_FIELDS) filename, mime, f = get_submission_file_info(request) @@ -71,7 +80,6 @@ def submission(): status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, ) - logger.info("prepare sending submission request") # form request to cluster-management api gpu_type = request.form.get("gpu_type") @@ -263,6 +271,8 @@ def is_rate_limited(user_id: int, window: dt.timedelta = WINDOW) -> bool: (user_id, cutoff), ) (cnt,) = cur.fetchone() or (0,) + + logger.info("fetched", cnt) limited = cnt >= RATE_LIMIT if limited: logger.warning( diff --git a/kernelboard/lib/db.py b/kernelboard/lib/db.py index deb09215..cae77ef4 100644 --- a/kernelboard/lib/db.py +++ b/kernelboard/lib/db.py @@ -26,14 +26,11 @@ def close_db_connection(e=None): if db is not None: try: if e is not None: - logger.info("Rolling back database connection") db.rollback() else: - logger.info("Committing database connection") db.commit() finally: db.close() - logger.info("Database connection closed") def init_app(app: Flask): From d7f7aeec2103b9648b1aa5dcef1c093217040ea6 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 26 Aug 2025 01:52:50 -0700 Subject: [PATCH 09/17] echo variables --- tests/api/test_submission_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/api/test_submission_api.py b/tests/api/test_submission_api.py index ce330f70..2a8a293d 100644 --- a/tests/api/test_submission_api.py +++ b/tests/api/test_submission_api.py @@ -13,7 +13,6 @@ _TEST_USER_ID = "333" _TEST_WEB_AUTH_ID = "111" - def _delete_user_graph(conn, user_id: str) -> None: """Idempotent cleanup: remove all rows under a user in FK-safe order.""" with conn: From 909b182de6c3ce1b2a8bf6372208454145a3317e Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 26 Aug 2025 01:58:26 -0700 Subject: [PATCH 10/17] echo variables --- frontend/src/api/api.ts | 2 +- frontend/src/lib/date/utils.ts | 2 +- tests/conftest.py | 11 +---------- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index d852e231..30182609 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -117,5 +117,5 @@ export async function fetchUserSubmissions( throw new APIError(`Failed to fetch submissions: ${message}`, res.status); } const r = await res.json(); - return r.data; // 直接返回 data 对象 { items, total, page, ... } + return r.data; } diff --git a/frontend/src/lib/date/utils.ts b/frontend/src/lib/date/utils.ts index cf8204ae..cd6f97bf 100644 --- a/frontend/src/lib/date/utils.ts +++ b/frontend/src/lib/date/utils.ts @@ -40,7 +40,7 @@ export const isExpired = ( if (typeof deadline === "string") { const parsed = new Date(deadline); if (isNaN(parsed.getTime())) { - return true; // 无效字符串,当作已过期 + return true; } d = parsed; } else { diff --git a/tests/conftest.py b/tests/conftest.py index 95d393f1..65b362cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -117,7 +117,7 @@ def db_server(): # Load data.sql into the template database: result = subprocess.run( [ - "/opt/homebrew/opt/postgresql@16/bin/psql", + "psql", "-h", "localhost", "-U", @@ -298,12 +298,3 @@ def client(app): @pytest.fixture def runner(app): return app.test_cli_runner() - -@pytest.fixture(autouse=True) -def set_env(monkeypatch): - monkeypatch.setenv("DATABASE_URL", get_test_db_info()["db_url"]) - monkeypatch.setenv("DISCORD_CLIENT_ID", "test") - monkeypatch.setenv("DISCORD_CLIENT_SECRET", "test") - monkeypatch.setenv("REDIS_URL", get_test_redis_url(get_test_redis_port())) - monkeypatch.setenv("SECRET_KEY", "test-secret") - monkeypatch.setenv("DISCORD_CLUSTER_MANAGER_API_BASE_URL", "http://discord_cluster_manager:8000") From c5828c309ae945ad6af1c73c25f68ab975ef49f5 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 26 Aug 2025 02:07:35 -0700 Subject: [PATCH 11/17] echo variables --- tests/api/test_submission_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api/test_submission_api.py b/tests/api/test_submission_api.py index 2a8a293d..a4253ea0 100644 --- a/tests/api/test_submission_api.py +++ b/tests/api/test_submission_api.py @@ -49,7 +49,7 @@ def seed_submissions(app, request): now = dt.datetime(2025, 1, 1, 0, 0, 0, tzinfo=dt.timezone.utc) with app.app_context(): conn = get_db_connection() - with conn: # 成功则提交,异常则回滚 + with conn: with conn.cursor() as cur: # 1) user_info upsert cur.execute( From ab999a6f6918923fd4e6cf1a096b6a3a21a963ab Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 26 Aug 2025 12:29:57 -0700 Subject: [PATCH 12/17] echo variables --- .../src/pages/leaderboard/Leaderboard.tsx | 2 +- .../SubmissionHistorySection.tsx | 2 +- kernelboard/__init__.py | 21 ++++- kernelboard/api/auth.py | 1 - kernelboard/api/submission.py | 81 ++++++------------- kernelboard/lib/rate_limiter.py | 12 +++ requirements.txt | 1 + tests/api/test_submission_api.py | 5 +- tests/conftest.py | 11 ++- 9 files changed, 70 insertions(+), 66 deletions(-) create mode 100644 kernelboard/lib/rate_limiter.py diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index d6cb1817..9d2dc5f8 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -200,7 +200,7 @@ export default function Leaderboard() { leaderboardName={data.name} gpuTypes={data.gpu_types} disabled={isExpired(data.deadline)} - modes={[SubmissionMode.LEADERBOARD, SubmissionMode.TEST]} + modes={[SubmissionMode.LEADERBOARD,SubmissionMode.BENCHMARK, SubmissionMode.TEST]} /> {/* Deadline Passed Message */} diff --git a/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx index 9faa2fca..8ecb7473 100644 --- a/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx +++ b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx @@ -124,7 +124,7 @@ export default function SubmissionHistorySection({ {/* Loading / Error */} - {loading && } + {loading && } {!loading && error && ( Failed to load submissions{errorStatus ? ` (${errorStatus})` : ""}:{" "} diff --git a/kernelboard/__init__.py b/kernelboard/__init__.py index 0484bf1f..5a288759 100644 --- a/kernelboard/__init__.py +++ b/kernelboard/__init__.py @@ -1,6 +1,8 @@ +import http import os +from re import L from dotenv import load_dotenv -from flask import Flask, jsonify, session +from flask import Flask, jsonify, session, g from flask_login import LoginManager, current_user from flask_session import Session from flask_talisman import Talisman @@ -11,7 +13,9 @@ from kernelboard.lib.redis_connection import create_redis_connection from flask import send_from_directory from kernelboard.lib.logging import configure_logging - +from flask_limiter import Limiter +from kernelboard.lib.rate_limiter import limiter +from kernelboard.lib.status_code import http_error def create_app(test_config=None): # Check if we're in development mode: @@ -54,11 +58,20 @@ def create_app(test_config=None): login_manager = LoginManager() + @login_manager.user_loader def load_user(user_id): return User(user_id) if user_id else None + @login_manager.unauthorized_handler + def unauthorized(): + return http_error( + message="Unauthorized", + status_code=http.HTTPStatus.UNAUTHORIZED, + ) + + login_manager.init_app(app) csp = { @@ -83,6 +96,10 @@ def load_user(user_id): db.init_app(app) + + # Initialize rate limiter + limiter.init_app(app) + app.add_template_filter(color.to_color, "to_color") app.add_template_filter(score.format_score, "format_score") app.add_template_filter(time.to_time_left, "to_time_left") diff --git a/kernelboard/api/auth.py b/kernelboard/api/auth.py index 47b400c4..7be944f7 100644 --- a/kernelboard/api/auth.py +++ b/kernelboard/api/auth.py @@ -16,7 +16,6 @@ from flask_login import UserMixin, current_user, login_user, logout_user from kernelboard.lib.auth_utils import ensure_user_info_with_token, get_user_info_from_session -from kernelboard.lib.db import get_db_connection from kernelboard.lib.status_code import http_success auth_bp = Blueprint("auth", __name__) diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index 6c843706..cfe7c713 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -1,20 +1,19 @@ import http from typing import Any, List, Optional, Tuple from flask import Blueprint, request, jsonify -import psycopg2 +from flask_login import login_required, current_user import requests -from werkzeug.exceptions import TooManyRequests from kernelboard.lib.auth_utils import ( get_id_and_username_from_session, - is_auth, ) from kernelboard.lib.db import get_db_connection from kernelboard.lib.error import ValidationError, validate_required_fields from kernelboard.lib.file_handler import get_submission_file_info from kernelboard.lib.status_code import http_error, http_success import logging -import datetime as dt import os +from kernelboard.lib.rate_limiter import limiter +import time logger = logging.getLogger(__name__) @@ -27,25 +26,23 @@ "submission_mode", ] -RATE_LIMIT = 10 -WINDOW = dt.timedelta(minutes=60) # official one: https://discord-cluster-manager-1f6c4782e60a.herokuapp.com/submission WEB_AUTH_HEADER = "X-Web-Auth-Id" -MAX_CONTENT_LENGTH = 20 * 1024 * 1024 # 20MB max file size - +MAX_CONTENT_LENGTH = 1 * 1024 * 1024 # 1MB max file size @submission_bp.route("/submission", methods=["POST"]) +@login_required +@limiter.limit( + "60 per minute", + exempt_when=lambda: not current_user.is_authenticated #ignore unauthenticated, since they won't hit the api +) + def submission(): # make sure user is logged in logger.info("submission received") - if not is_auth(): - logger.error("user did not login") - return http_error( - message="cannnot get user id, please log in first, if this is unexpected, please contact the gpumode administrator", - status_code=http.HTTPStatus.UNAUTHORIZED, - ) user_id, username = get_id_and_username_from_session() + log_rate_limit() web_token = get_user_token(user_id) if not web_token: @@ -56,13 +53,6 @@ def submission(): ) req = request.form.to_dict() - rate_limited = is_rate_limited(user_id=user_id, window=WINDOW) - if rate_limited: - return http_error( - message=f"Execeeding rate limit: {RATE_LIMIT} submission per {WINDOW.seconds // 60} minutes, please try again later", - status_code=http.HTTPStatus.TOO_MANY_REQUESTS, - ) - try: validate_required_fields(req, REQUIRED_SUBMISSION_REQUEST_FIELDS) filename, mime, f = get_submission_file_info(request) @@ -127,6 +117,7 @@ def submission(): @submission_bp.route("/submissions", methods=["GET"]) +@login_required def list_submissions(): """ GET /submissions?leaderboard_id=123&&limit=20&offset=0 @@ -136,14 +127,8 @@ def list_submissions(): # submit method: discord-bot vs cli vs web # submit request info: mode and gpu type # this could be a followup to provide more information - logger.info("list submission request is received") - if not is_auth(): - return http_error( - message="cannnot get user id, please log in first, if this is unexpected, please contact the gpumode administrator", - code=10000 + http.HTTPStatus.UNAUTHORIZED.value, - status_code=http.HTTPStatus.UNAUTHORIZED, - ) + user_id, _ = get_id_and_username_from_session() leaderboard_id = request.args.get("leaderboard_id", type=int) limit = request.args.get("limit", default=20, type=int) @@ -255,35 +240,6 @@ def list_user_submissions_with_status( return items, total -def is_rate_limited(user_id: int, window: dt.timedelta = WINDOW) -> bool: - now = dt.datetime.now(dt.timezone.utc) - cutoff = now - window - - conn = get_db_connection() - with conn.cursor() as cur: - cur.execute( - """ - SELECT COUNT(*) - FROM leaderboard.submission - WHERE user_id = %s - AND submission_time >= %s - """, - (user_id, cutoff), - ) - (cnt,) = cur.fetchone() or (0,) - - logger.info("fetched", cnt) - limited = cnt >= RATE_LIMIT - if limited: - logger.warning( - "User %s hit rate limit: %s submissions in last %s minutes", - user_id, - cnt, - int(window.total_seconds() // 60), - ) - return limited - - def get_user_token(user_id: int) -> Optional[str]: conn = get_db_connection() with conn.cursor() as cur: @@ -298,3 +254,14 @@ def get_user_token(user_id: int) -> Optional[str]: row = cur.fetchone() # row will be a tuple like (token,) or None return row[0] if row else None + + +def log_rate_limit(): + rl = limiter.current_limit + used = remaining = limit_ = reset_in = None + if rl: + limit_ = int(rl.limit.amount) + remaining = max(0, int(rl.remaining)) + used = limit_ - remaining + reset_in = max(0, int(rl.reset_at - time.time())) + logger.info(f"rate limit: {limit_},used {used}, reset{reset_in}") diff --git a/kernelboard/lib/rate_limiter.py b/kernelboard/lib/rate_limiter.py new file mode 100644 index 00000000..f78d0fca --- /dev/null +++ b/kernelboard/lib/rate_limiter.py @@ -0,0 +1,12 @@ +import os +from flask_limiter import Limiter +from flask_login import current_user + +# this only limits the number of requests per user, not per IP address +limiter = Limiter( + key_func=lambda: f"user:{current_user.get_id()}", + storage_uri=os.environ.get("REDIS_URL"), + strategy="moving-window", + headers_enabled=True, + default_limits=[], # no default limits, we'll set them in the routes +) diff --git a/requirements.txt b/requirements.txt index 7220ad3c..66107fcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,3 +22,4 @@ requests>=2.32.3,<3.0.0 urllib3>=2.4.0,<3.0.0 Werkzeug>=3.1.3,<3.2.0 pyyaml>=6.0.1 +flask-limiter>=3.12 diff --git a/tests/api/test_submission_api.py b/tests/api/test_submission_api.py index a4253ea0..88244536 100644 --- a/tests/api/test_submission_api.py +++ b/tests/api/test_submission_api.py @@ -151,6 +151,7 @@ def _prepare(auth: bool = True, web_token: bool = True): # Patch current user fake_user = SimpleNamespace( is_anonymous=False, + is_authenticated=True, get_id=lambda: f"discord:{_TEST_USER_ID}", ) monkeypatch.setattr(flask_login.utils, "_get_user", lambda: fake_user) @@ -163,7 +164,7 @@ def _cleanup(): request.addfinalizer(_cleanup) else: # Explicit anonymous user - anon = SimpleNamespace(is_anonymous=True, get_id=lambda: None) + anon = SimpleNamespace(is_anonymous=True,is_authenticated=False, get_id=lambda: None) monkeypatch.setattr(flask_login.utils, "_get_user", lambda: anon) return _prepare @@ -219,12 +220,10 @@ def test_submission_happy_path(app, client, prepare): def test_submission_unauthorized(app, client, prepare): # No auth prepare(auth=False) - resp = _post_submission(client) assert resp.status_code == http.HTTPStatus.UNAUTHORIZED js = resp.get_json() assert js["code"] == 10000 + http.HTTPStatus.UNAUTHORIZED - assert "log in" in js["message"].lower() def test_submission_missing_web_token(app, client, prepare): diff --git a/tests/conftest.py b/tests/conftest.py index 65b362cd..18c74d7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -117,7 +117,7 @@ def db_server(): # Load data.sql into the template database: result = subprocess.run( [ - "psql", + "/opt/homebrew/opt/postgresql@16/bin/psql", "-h", "localhost", "-U", @@ -298,3 +298,12 @@ def client(app): @pytest.fixture def runner(app): return app.test_cli_runner() + +@pytest.fixture(autouse=True) +def set_env(monkeypatch): + monkeypatch.setenv("DATABASE_URL", get_test_db_info()["db_url"]) + monkeypatch.setenv("DISCORD_CLIENT_ID", "test") + monkeypatch.setenv("DISCORD_CLIENT_SECRET", "test") + monkeypatch.setenv("REDIS_URL", get_test_redis_url(get_test_redis_port())) + monkeypatch.setenv("SECRET_KEY", "test-secret") + monkeypatch.setenv("DISCORD_CLUSTER_MANAGER_API_BASE_URL", "test-secret") From 8b93fc95687baaa49a7f2bbabaf78107772c6483 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 26 Aug 2025 12:33:19 -0700 Subject: [PATCH 13/17] echo variables --- frontend/src/pages/leaderboard/Leaderboard.tsx | 6 +++++- .../pages/leaderboard/components/LeaderboardSubmit.test.tsx | 2 +- .../src/pages/leaderboard/components/LeaderboardSubmit.tsx | 2 +- .../submission-history/SubmissionHistorySection.tsx | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index 9d2dc5f8..ff20fc80 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -200,7 +200,11 @@ export default function Leaderboard() { leaderboardName={data.name} gpuTypes={data.gpu_types} disabled={isExpired(data.deadline)} - modes={[SubmissionMode.LEADERBOARD,SubmissionMode.BENCHMARK, SubmissionMode.TEST]} + modes={[ + SubmissionMode.LEADERBOARD, + SubmissionMode.BENCHMARK, + SubmissionMode.TEST, + ]} /> {/* Deadline Passed Message */} diff --git a/frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx index 629f0e48..14d76ba2 100644 --- a/frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx +++ b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx @@ -123,7 +123,7 @@ describe("LeaderboardSubmit (Vitest)", () => { await userEvent.upload(input, createFile({ name: "big.py", sizeMB: 6 })); expect( await screen.findByTestId("submission-dialog-error-alert"), - ).toHaveTextContent(/File too large \(> 5 MB\)/i); + ).toHaveTextContent(/File too large \(> 1 MB\)/i); // valid .py -> error disappears, filename shown await userEvent.upload(input, createFile({ name: "algo.py", sizeMB: 1 })); diff --git a/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx index b466f3fa..c0e14b22 100644 --- a/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx +++ b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx @@ -81,7 +81,7 @@ export default function LeaderboardSubmit({ function validatePythonFile(f: File): string | null { // set a max file size too - const MAX_MB = 5; + const MAX_MB = 1; const name = f.name.toLowerCase(); if (!name.endsWith(".py")) return "Please select a .py file."; if (f.size > MAX_MB * 1024 * 1024) return `File too large (> ${MAX_MB} MB)`; diff --git a/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx index 8ecb7473..713b54b4 100644 --- a/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx +++ b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx @@ -124,7 +124,7 @@ export default function SubmissionHistorySection({ {/* Loading / Error */} - {loading && } + {loading && } {!loading && error && ( Failed to load submissions{errorStatus ? ` (${errorStatus})` : ""}:{" "} From 77eb19317d8e9e687e41dee61b7c7619114959c1 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 26 Aug 2025 12:55:50 -0700 Subject: [PATCH 14/17] echo variables --- frontend/README.md | 2 +- .../src/components/codeblock/CodeBlock.tsx | 91 ++++++------------- .../pages/leaderboard/Leaderboard.test.tsx | 15 +-- kernelboard/api/submission.py | 1 - 4 files changed, 33 insertions(+), 76 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index 5c636c86..c9731fbf 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -20,7 +20,7 @@ Assume you have a toggle component that can be expanded by user click. ``` // ToggleShowMore.tsx export function ToggleShowMore() { - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(true); return ( diff --git a/frontend/src/components/codeblock/CodeBlock.tsx b/frontend/src/components/codeblock/CodeBlock.tsx index 14f70474..b5061a3a 100644 --- a/frontend/src/components/codeblock/CodeBlock.tsx +++ b/frontend/src/components/codeblock/CodeBlock.tsx @@ -14,60 +14,27 @@ import ExpandLessIcon from "@mui/icons-material/ExpandLess"; interface CodeBlockProps { code: string; - maxHeight?: number; + maxHeight?: number | string; } -export const styles = { +const styles = { container: { position: "relative", - border: "1px solid #ddd", - borderRadius: 2, - bgcolor: "#f9f9f9", - fontFamily: "monospace", - overflow: "hidden", }, - copyButton: { position: "absolute", - top: 8, - right: 8, + top: 4, + right: 4, zIndex: 1, }, - - toggleText: { - cursor: "pointer", - color: "primary.main", - display: "inline-flex", - alignItems: "center", - userSelect: "none", - }, - - fadeOverlay: (theme: Theme): SxProps => ({ - position: "absolute", - bottom: 0, - left: 0, - right: 0, - height: 48, - background: `linear-gradient(to bottom, rgba(249,249,249,0), ${theme.palette.background.paper})`, - pointerEvents: "none", - }), - - prestyle(expanded: boolean, maxHeight: number): SxProps { - return { - m: 0, - px: 2, - py: 2, - maxHeight: expanded ? "none" : `${maxHeight}px`, - overflowX: "auto", - overflowY: expanded ? "visible" : "hidden", - whiteSpace: "pre", - position: "relative", - }; + pre: { + fontFamily: "monospace", + whiteSpace: "pre-wrap", + wordBreak: "break-word", }, }; -export default function CodeBlock({ code, maxHeight = 160 }: CodeBlockProps) { - const [expanded, setExpanded] = useState(false); +export default function CodeBlock({ code }: CodeBlockProps) { const [copied, setCopied] = useState(false); const theme = useTheme(); @@ -78,8 +45,6 @@ export default function CodeBlock({ code, maxHeight = 160 }: CodeBlockProps) { }); }; - // dynamically render the pre based on the expanded state - return ( {/* Copy Button */} @@ -91,27 +56,25 @@ export default function CodeBlock({ code, maxHeight = 160 }: CodeBlockProps) { - {/* Code */} - + {/* Scrollable Code */} + {code} - {!expanded && } - - - {/* Toggle */} - - setExpanded((e) => !e)} - > - {expanded ? "Hide" : "Show more"} - {expanded ? ( - - ) : ( - - )} - ); diff --git a/frontend/src/pages/leaderboard/Leaderboard.test.tsx b/frontend/src/pages/leaderboard/Leaderboard.test.tsx index 99a103fa..52bb4576 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.test.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.test.tsx @@ -275,9 +275,9 @@ describe("Leaderboard", () => { expect(screen.queryAllByTestId("ranking-0-row")).toHaveLength(3); }); - // -------------------- Reference codeblock toggle -------------------- + // -------------------- Reference codeblock -------------------- - it("toggles expanded state for reference codeblock (after switching to Reference tab)", () => { + it("show reference codeblock (after switching to Reference tab)", () => { const mockData = { name: "test-code", description: "", @@ -300,14 +300,9 @@ describe("Leaderboard", () => { // Must switch to Reference tab first fireEvent.click(screen.getByRole("tab", { name: /Reference/i })); - const toggle = screen.getByTestId("codeblock-show-all-toggle"); - expect(within(toggle).getByText(/show more/i)).toBeInTheDocument(); - - fireEvent.click(toggle); - expect(within(toggle).getByText(/hide/i)).toBeInTheDocument(); - - fireEvent.click(toggle); - expect(within(toggle).getByText(/show more/i)).toBeInTheDocument(); + // Reference codeblock should be visible + expect(screen.getByText(/Reference Implementation/i)).toBeInTheDocument(); + expect(screen.getByText(mockReference)).toBeInTheDocument(); }); // -------------------- Tabs behavior (switching) -------------------- diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index cfe7c713..ae421b22 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -37,7 +37,6 @@ "60 per minute", exempt_when=lambda: not current_user.is_authenticated #ignore unauthenticated, since they won't hit the api ) - def submission(): # make sure user is logged in logger.info("submission received") From e522ebd8ade4d44dd657f818906b82845b5e8a1d Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Wed, 27 Aug 2025 17:07:04 -0700 Subject: [PATCH 15/17] Update README.md Co-authored-by: Ben Horowitz --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c3cfbc3d..df2e2eeb 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Here's how to get started: 6. [Optional] if you want to test submission end to end, you need to run and setup [discord-cluster-manager](https://github.com/gpu-mode/discord-cluster-manager), otherwise, just set DISCORD_CLUSTER_MANAGER_API_BASE_URL to a dummy url in .env file. 7. Finally, create a .env file in the root directory of your sandbox with - SECRET_KEY, DATABASE_URL, REDIS_and DISCORD_CLUSTER_MANAGER_API_BASE_URL URL entries. The secret key can be + SECRET_KEY, DATABASE_URL, REDIS_URL and DISCORD_CLUSTER_MANAGER_API_BASE_URL URL entries. The secret key can be anything you like; `dev` will work well. ```env From 2eb58373c65d14b8d48a956f61ec30dd3af0d712 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Wed, 27 Aug 2025 17:08:59 -0700 Subject: [PATCH 16/17] add workflow to dispatch --- kernelboard/api/submission.py | 1 - tests/conftest.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index ae421b22..7b6a558a 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -27,7 +27,6 @@ ] -# official one: https://discord-cluster-manager-1f6c4782e60a.herokuapp.com/submission WEB_AUTH_HEADER = "X-Web-Auth-Id" MAX_CONTENT_LENGTH = 1 * 1024 * 1024 # 1MB max file size diff --git a/tests/conftest.py b/tests/conftest.py index 18c74d7b..bbf37054 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -117,7 +117,7 @@ def db_server(): # Load data.sql into the template database: result = subprocess.run( [ - "/opt/homebrew/opt/postgresql@16/bin/psql", + "psql", "-h", "localhost", "-U", From 3d9b3bb1330961450ffefa5dbd820daf4404e6bf Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Wed, 27 Aug 2025 17:20:16 -0700 Subject: [PATCH 17/17] add workflow to dispatch --- kernelboard/lib/db.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kernelboard/lib/db.py b/kernelboard/lib/db.py index cae77ef4..c1c542f4 100644 --- a/kernelboard/lib/db.py +++ b/kernelboard/lib/db.py @@ -27,6 +27,8 @@ def close_db_connection(e=None): try: if e is not None: db.rollback() + logger.error("DB error, rolling back: %s", e) + else: db.commit() finally: