diff --git a/.gitignore b/.gitignore index adb75b7..2d18bcf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ htmlcov/ node_modules/ .env +.env.test .vscode *.DS_Store kernelboard/static/app diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..f5e09e8 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + postgres: + image: postgres:16 + container_name: kernelboard-postgres + environment: + POSTGRES_USER: elainewy + POSTGRES_PASSWORD: dev + POSTGRES_DB: kernelboard + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./tests/data.sql:/docker-entrypoint-initdb.d/data.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U elainewy -d kernelboard"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: kernelboard-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d3fa6c..7b90286 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,14 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@codemirror/lang-python": "^6.2.1", + "@codemirror/theme-one-dark": "^6.1.3", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", "@types/react-syntax-highlighter": "^15.5.13", + "@uiw/react-codemirror": "^4.25.7", "dayjs": "^1.11.13", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", @@ -361,6 +364,103 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz", + "integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz", + "integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", + "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.39.16", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz", + "integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -1255,6 +1355,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==" + }, "node_modules/@mui/core-downloads-tracker": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.2.0.tgz", @@ -2343,6 +2479,57 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.7", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.7.tgz", + "integrity": "sha512-tPV/AGjF4yM22D5mnyH7EuYBkWO05wF5Y4x3lmQJo6LuHmhjh0RQsVDjqeIgNOkXT3UO9OdkL4dzxw465/JZVg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.7", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.7.tgz", + "integrity": "sha512-s/EbEe0dFANWEgfLbfdIrrOGv0R7M1XhkKG3ShroBeH6uP9pVNQy81YHOLRCSVcytTp9zAWRNfXR/+XxZTvV7w==", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.7", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2785,6 +2972,20 @@ "node": ">=6" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2868,6 +3069,11 @@ "node": ">= 6" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6154,6 +6360,11 @@ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==" + }, "node_modules/style-to-js": { "version": "1.1.17", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.17.tgz", @@ -6821,6 +7032,11 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 161f637..30df2f4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,11 +12,14 @@ "test": "vitest" }, "dependencies": { + "@codemirror/lang-python": "^6.2.1", + "@codemirror/theme-one-dark": "^6.1.3", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", "@types/react-syntax-highlighter": "^15.5.13", + "@uiw/react-codemirror": "^4.25.7", "dayjs": "^1.11.13", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 446875c..c7942bb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import AppLayout from "./components/app-layout/AppLayout"; import { CssBaseline, ThemeProvider } from "@mui/material"; import { createAppTheme } from "./components/common/styles/theme"; import Leaderboard from "./pages/leaderboard/Leaderboard"; +import LeaderboardEditor from "./pages/leaderboard/LeaderboardEditor"; import Home from "./pages/home/Home"; import News from "./pages/news/News"; import WorkingGroups from "./pages/working-groups/WorkingGroups"; @@ -68,6 +69,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index e8238b5..eacc266 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -387,3 +387,99 @@ export async function searchUsers( const r = await res.json(); return r.data; } + +export interface SubmitCodeResponse { + submission_id: number; + message?: string; +} + +export async function submitCode( + leaderboardId: string, + leaderboardName: string, + gpuType: string, + mode: string, + code: string, + fileName: string = "submission.py" +): Promise { + const blob = new Blob([code], { type: "text/plain" }); + const file = new File([blob], fileName, { type: "text/x-python" }); + + const form = new FormData(); + form.set("leaderboard_id", leaderboardId); + form.set("leaderboard", leaderboardName); + form.set("gpu_type", gpuType); + form.set("submission_mode", mode); + form.set("file", file, fileName); + + let resp: Response; + try { + resp = await fetch("/api/submission", { + method: "POST", + body: form, + }); + } catch (_err) { + throw new Error("Network error: Unable to connect to server"); + } + + const text = await resp.text(); + if (!text) { + throw new Error("Server returned empty response. The submission service may be unavailable."); + } + + let data: Record; + try { + data = JSON.parse(text); + } catch { + throw new Error(`Server error: ${text.slice(0, 200)}`); + } + + if (!resp.ok) { + const msg = (data?.detail as string) || (data?.message as string) || "Submission failed"; + throw new Error(msg); + } + + return { + submission_id: (data.data as Record)?.submission_id as number || 0, + message: data.message as string | undefined, + }; +} + +export interface SubmissionStatusResponse { + submission_id: number; + status: string | null; + submission_done: boolean; + file_name?: string | null; + submitted_at?: string; + error?: string | null; + last_heartbeat?: string | null; + job_created_at?: string | null; + runs?: Array<{ + start_time: string; + end_time: string | null; + mode: string; + passed: boolean; + score: number | null; + meta: Record | null; + report: Record | null; + }>; +} + +export async function fetchSubmissionStatus( + leaderboardId: number | string, + submissionId: number +): Promise { + const res = await fetch( + `/api/submissions?leaderboard_id=${leaderboardId}&offset=0&limit=100` + ); + if (!res.ok) { + const json = await res.json(); + const message = json?.message || "Unknown error"; + throw new APIError(`Failed to fetch submission status: ${message}`, res.status); + } + const r = await res.json(); + const items = r.data?.items || []; + const submission = items.find( + (item: SubmissionStatusResponse) => item.submission_id === submissionId + ); + return submission || null; +} diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index 26baf56..deb23c9 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -7,6 +7,7 @@ import { Tab, Tabs, Typography, + Button, } from "@mui/material"; import Grid from "@mui/material/Grid"; import { memo, useCallback, useEffect, useState } from "react"; @@ -17,19 +18,28 @@ import RankingsList from "./components/RankingLists"; import CodeBlock from "../../components/codeblock/CodeBlock"; import MarkdownRenderer from "../../components/markdown-renderer/MarkdownRenderer"; import { ErrorAlert } from "../../components/alert/ErrorAlert"; -import { useParams, useSearchParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; 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 SubmissionHistorySection from "./components/submission-history/SubmissionHistorySection"; -import LeaderboardSubmit from "./components/LeaderboardSubmit"; import UserTrendChart from "./components/UserTrendChart"; import { SubmissionSidebarProvider, useSubmissionSidebarState, } from "./components/SubmissionSidebarContext"; import SubmissionCodeSidebar from "./components/SubmissionCodeSidebar"; +import CodeIcon from "@mui/icons-material/Code"; +import TerminalIcon from "@mui/icons-material/Terminal"; +import LeaderboardSubmit from "./components/LeaderboardSubmit"; +import { SubmissionMode } from "../../lib/types/mode"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from "@mui/material"; +import quickStartMarkdown from "../home/quick-start.md?raw"; const DEFAULT_SIDEBAR_WIDTH = 600; @@ -71,6 +81,7 @@ function TabPanel(props: { // Inner component const LeaderboardContent = memo(function LeaderboardContent() { const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); const { data, loading, error, errorStatus, call } = fetcherApiCallback(fetchLeaderBoard); @@ -97,7 +108,7 @@ const LeaderboardContent = memo(function LeaderboardContent() { })(); const [tab, setTab] = useState(initialTabFromUrl); const [refreshFlag, setRefreshFlag] = useState(false); - const triggerRefresh = () => setRefreshFlag((f) => !f); + const [isQuickStartOpen, setIsQuickStartOpen] = useState(false); useEffect(() => { const current = searchParams.get("tab"); @@ -169,6 +180,7 @@ const LeaderboardContent = memo(function LeaderboardContent() { if (loading) return ; if (error) return ; + if (!data) return null; const toDeadlineUTC = (raw: string) => { const verb = isExpired(raw) ? "Ended" : "Ends"; @@ -183,7 +195,57 @@ const LeaderboardContent = memo(function LeaderboardContent() { return ( -

{data.name}

+ {/* Header with title and Submit button */} + +

{data.name}

+ + + {searchParams.has("editor") && ( + + )} + +
+ + {/* Quick Start Dialog */} + setIsQuickStartOpen(false)} + maxWidth="md" + fullWidth + > + Submit Your First Kernel + + + + + + + {/* Header info cards shown above tabs */} {info_items.map((info, idx) => ( @@ -259,13 +321,23 @@ const LeaderboardContent = memo(function LeaderboardContent() { ) : ( - + No Submission Yet Be the first to submit a solution for this challenge! + )} @@ -297,19 +369,16 @@ const LeaderboardContent = memo(function LeaderboardContent() { justifyContent="space-between" mb={2} > - Submission - + Submission History + {!isExpired(data.deadline) && ( + setRefreshFlag((f) => !f)} + /> + )} {/* Deadline Passed Message */} {isExpired(data.deadline) && ( @@ -334,7 +403,6 @@ const LeaderboardContent = memo(function LeaderboardContent() { )} -
); diff --git a/frontend/src/pages/leaderboard/LeaderboardEditor.tsx b/frontend/src/pages/leaderboard/LeaderboardEditor.tsx new file mode 100644 index 0000000..9cd1d6e --- /dev/null +++ b/frontend/src/pages/leaderboard/LeaderboardEditor.tsx @@ -0,0 +1,487 @@ +import { + Box, + Card, + CardContent, + Typography, + Button, + Alert, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + DialogContentText, + useMediaQuery, + useTheme, + Stack, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import HistoryIcon from "@mui/icons-material/History"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + fetchLeaderBoard, + submitCode, + fetchSubmissionStatus, +} from "../../api/api"; +import { fetcherApiCallback } from "../../lib/hooks/useApi"; +import Loading from "../../components/common/loading"; +import { ErrorAlert } from "../../components/alert/ErrorAlert"; +import MarkdownRenderer from "../../components/markdown-renderer/MarkdownRenderer"; +import { SubmissionMode } from "../../lib/types/mode"; +import { useAuthStore } from "../../lib/store/authStore"; +import SubmissionHistorySection from "./components/submission-history/SubmissionHistorySection"; +import { useThemeStore } from "../../lib/store/themeStore"; +import { + CodeEditorPanel, + JobOutputPanel, + EditorControls, + ResizableSplitPanel, + editorStyles as styles, + type SubmitStatus, +} from "./components/editor"; + +const DEFAULT_CODE = `# Write your code here`; + +export default function LeaderboardEditor() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const resolvedMode = useThemeStore((s) => s.resolvedMode); + const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up("md")); + + 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; + + // Resizable side panel state (for desktop) + const [sidePanelWidth, setSidePanelWidth] = useState(400); + const [isResizing, setIsResizing] = useState(false); + const containerRef = useRef(null); + + // Editor state + const [code, setCode] = useState(DEFAULT_CODE); + const [isEditorDirty, setIsEditorDirty] = useState(true); + const [editorStatus, setEditorStatus] = useState({ kind: "idle" }); + const editorPollingRef = useRef | null>(null); + const editorTimeoutRef = useRef | null>(null); + const fileInputRef = useRef(null); + + // Polling timeout (15 minutes) + const POLLING_TIMEOUT_MS = 15 * 60 * 1000; + + // Common state + const [gpuType, setGpuType] = useState(""); + const [mode, setMode] = useState(SubmissionMode.LEADERBOARD); + const [historyOpen, setHistoryOpen] = useState(false); + const [refreshFlag] = useState(false); + const [confirmSubmitOpen, setConfirmSubmitOpen] = useState(false); + + const modes = useMemo( + () => [SubmissionMode.LEADERBOARD, SubmissionMode.BENCHMARK, SubmissionMode.TEST], + [] + ); + + // Handle panel resize + const handleMouseDown = useCallback(() => { + setIsResizing(true); + }, []); + + useEffect(() => { + let rafId: number | null = null; + + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing || !containerRef.current) return; + + if (rafId) cancelAnimationFrame(rafId); + + rafId = requestAnimationFrame(() => { + if (!containerRef.current) return; + const containerRect = containerRef.current.getBoundingClientRect(); + const newWidth = e.clientX - containerRect.left; + // Clamp between 250 and 600 + setSidePanelWidth(Math.max(250, Math.min(600, newWidth))); + }); + }; + + const handleMouseUp = () => { + if (rafId) cancelAnimationFrame(rafId); + setIsResizing(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + if (isResizing) { + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + } + + return () => { + if (rafId) cancelAnimationFrame(rafId); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isResizing]); + + useEffect(() => { + if (id) call(id); + }, [id, call]); + + useEffect(() => { + if (data?.gpu_types?.length && !gpuType) { + setGpuType(data.gpu_types[0]); + } + }, [data?.gpu_types, gpuType]); + + // Editor polling + const stopEditorPolling = useCallback(() => { + if (editorPollingRef.current) { + clearInterval(editorPollingRef.current); + editorPollingRef.current = null; + } + if (editorTimeoutRef.current) { + clearTimeout(editorTimeoutRef.current); + editorTimeoutRef.current = null; + } + }, []); + + const startEditorPolling = useCallback( + (submissionId: number) => { + if (!id) return; + stopEditorPolling(); + + const poll = async () => { + try { + const status = await fetchSubmissionStatus(id, submissionId); + if (!status) return; + if (status.submission_done) { + stopEditorPolling(); + setEditorStatus({ kind: "done", submissionId, result: status }); + } else { + // Show intermediate status with runs while polling + setEditorStatus({ kind: "polling", submissionId, result: status }); + } + } catch (err) { + console.error("Editor polling error:", err); + } + }; + + // Set timeout (20 minutes) + editorTimeoutRef.current = setTimeout(() => { + stopEditorPolling(); + setEditorStatus({ + kind: "error", + msg: "Job timed out after 15 minutes. Please try again or refresh it manually.", + }); + }, POLLING_TIMEOUT_MS); + + poll(); + editorPollingRef.current = setInterval(poll, 5000); + }, + [stopEditorPolling, id, POLLING_TIMEOUT_MS] + ); + + useEffect(() => { + return () => { + stopEditorPolling(); + }; + }, [stopEditorPolling]); + + // Editor submit - check if job is running first + const handleEditorSubmitClick = () => { + if (!data || !id) return; + + if (!code.trim()) { + setEditorStatus({ kind: "error", msg: "Please write some code before submitting." }); + return; + } + + // If a job is currently running, show confirmation dialog + if (editorStatus.kind === "polling") { + setConfirmSubmitOpen(true); + return; + } + + // Otherwise, submit directly + doEditorSubmit(); + }; + + const doEditorSubmit = async () => { + if (!data || !id) return; + + setConfirmSubmitOpen(false); + setEditorStatus({ kind: "submitting" }); + + try { + const result = await submitCode(id, data.name, gpuType, mode, code); + if (result?.submission_id) { + setIsEditorDirty(false); + startEditorPolling(result.submission_id); + } else { + setEditorStatus({ kind: "error", msg: "Submission accepted but no ID returned." }); + } + } catch (err) { + setEditorStatus({ + kind: "error", + msg: err instanceof Error ? err.message : "Submission failed", + }); + } + }; + + const canEditorSubmit = useMemo(() => { + if (!gpuType || !mode) return false; + if (!code.trim()) return false; + // Only enable if editor has been modified + if (!isEditorDirty) return false; + return true; + }, [code, gpuType, mode, isEditorDirty]); + + if (loading) return ; + if (error) return ; + if (!data) return null; + + if (!isAuthed) { + return ( + + + + Submit Code + + + Please login to submit your code. + + + + ); + } + + return ( + + + {/* Header */} + + + + {data.name} + + + Submit your Python code + + + + + + + + + {/* Main Content - Side by side on desktop, stacked on mobile */} + + {/* Description Panel */} + + + + + Challenge Description + + + {data.benchmarks && data.benchmarks.length > 0 && ( + + + Benchmark Shapes + +
    + {data.benchmarks.map((b, i) => ( +
  • + + {JSON.stringify( + Object.fromEntries(Object.entries(b).filter(([k]) => k !== "seed")) + )} + +
  • + ))} +
+
+ )} +
+
+
+ + {/* Resize Handle (desktop only) */} + {isDesktop && ( + + + + )} + + {/* Editor Panel */} + + {/* Submission Section */} + + + {/* Hidden file input */} + { + const selectedFile = e.target.files?.[0]; + if (!selectedFile) return; + if (!selectedFile.name.endsWith(".py")) { + setEditorStatus({ kind: "error", msg: "Please select a .py file" }); + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + const content = event.target?.result as string; + setCode(content); + setIsEditorDirty(true); + }; + reader.onerror = () => { + setEditorStatus({ kind: "error", msg: "Failed to read file" }); + }; + reader.readAsText(selectedFile); + }} + /> + + {/* Editor + Controls + Output - all fit screen */} + { + setCode(value); + setIsEditorDirty(true); + }} + resolvedMode={resolvedMode} + /> + } + middleContent={ + <> + fileInputRef.current?.click()} + /> + {editorStatus.kind === "error" && ( + + {editorStatus.msg} + + )} + {editorStatus.kind === "done" && ( + + Submission completed! + + )} + + } + bottomPanel={ + + } + /> + + + + + + {/* History Dialog */} + setHistoryOpen(false)} + maxWidth="md" + fullWidth + > + + Submission History + setHistoryOpen(false)}> + + + + + + + + + {/* Confirm Submit Dialog */} + setConfirmSubmitOpen(false)} + > + Submit New Code? + + + A job is currently running. Are you sure you want to submit new code? + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx index 4c1b681..1c9db90 100644 --- a/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx +++ b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx @@ -48,6 +48,8 @@ export default function LeaderboardSubmit({ modes, disabled = false, onSubmit, + open: externalOpen, + onClose: externalOnClose, }: { leaderboardId: string; leaderboardName: string; @@ -55,8 +57,17 @@ export default function LeaderboardSubmit({ modes: string[]; disabled?: boolean; onSubmit?: () => void; + open?: boolean; + onClose?: () => void; }) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const isControlled = externalOpen !== undefined; + const open = isControlled ? externalOpen : internalOpen; + const setOpen = isControlled + ? (val: boolean) => { + if (!val && externalOnClose) externalOnClose(); + } + : setInternalOpen; const [gpuType, setGpuType] = useState(gpuTypes?.[0] ?? ""); const [mode, setMode] = useState(modes?.[0] ?? ""); const [file, setFile] = useState(null); @@ -137,22 +148,27 @@ export default function LeaderboardSubmit({ } }, [status]); - const renderSubmitButton = () => ( - - ); + const renderSubmitButton = () => { + if (isControlled) { + return null; + } + return ( + + ); + }; return ( <> + + + + + {title} + + + + + Challenge Description + + + + + {benchmarkShapes && benchmarkShapes.length > 0 && ( + + + Benchmark Shapes + + + {benchmarkShapes.map((shape, idx) => ( + + ))} + + + )} + + + ); +} diff --git a/frontend/src/pages/leaderboard/components/editor/CodeEditorPanel.tsx b/frontend/src/pages/leaderboard/components/editor/CodeEditorPanel.tsx new file mode 100644 index 0000000..2ffaca0 --- /dev/null +++ b/frontend/src/pages/leaderboard/components/editor/CodeEditorPanel.tsx @@ -0,0 +1,42 @@ +import { Box } from "@mui/material"; +import CodeMirror from "@uiw/react-codemirror"; +import { python } from "@codemirror/lang-python"; +import { oneDark } from "@codemirror/theme-one-dark"; + +interface CodeEditorPanelProps { + code: string; + onChange: (value: string) => void; + resolvedMode: "light" | "dark"; + height?: string; + style?: React.CSSProperties; +} + +export function CodeEditorPanel({ + code, + onChange, + resolvedMode, + height = "100%", + style, +}: CodeEditorPanelProps) { + return ( + + + + ); +} diff --git a/frontend/src/pages/leaderboard/components/editor/EditorControls.tsx b/frontend/src/pages/leaderboard/components/editor/EditorControls.tsx new file mode 100644 index 0000000..f617015 --- /dev/null +++ b/frontend/src/pages/leaderboard/components/editor/EditorControls.tsx @@ -0,0 +1,105 @@ +import { + Stack, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + Tooltip, + CircularProgress, +} from "@mui/material"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import { editorStyles } from "./types"; + +interface EditorControlsProps { + gpuType: string; + setGpuType: (value: string) => void; + gpuTypes: string[]; + mode: string; + setMode: (value: string) => void; + modes: string[]; + canSubmit: boolean; + isSubmitting: boolean; + onSubmit: () => void; + onUploadClick: () => void; +} + +export function EditorControls({ + gpuType, + setGpuType, + gpuTypes, + mode, + setMode, + modes, + canSubmit, + isSubmitting, + onSubmit, + onUploadClick, +}: EditorControlsProps) { + return ( + + + GPU Type + + + + + Mode + + + + + + + + + + ); +} diff --git a/frontend/src/pages/leaderboard/components/editor/JobOutputPanel.tsx b/frontend/src/pages/leaderboard/components/editor/JobOutputPanel.tsx new file mode 100644 index 0000000..31c97ae --- /dev/null +++ b/frontend/src/pages/leaderboard/components/editor/JobOutputPanel.tsx @@ -0,0 +1,167 @@ +import { Box, Stack, Typography, Chip, CircularProgress } from "@mui/material"; +import TerminalIcon from "@mui/icons-material/Terminal"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import { type SubmitStatus } from "./types"; + +interface JobOutputPanelProps { + editorStatus: SubmitStatus; + uploadStatus: SubmitStatus; +} + +export function JobOutputPanel({ editorStatus, uploadStatus }: JobOutputPanelProps) { + return ( + + + + + Job Output + + {(editorStatus.kind === "polling" || uploadStatus.kind === "polling") && ( + } + label="Running" + color="warning" + size="small" + sx={{ height: 20, fontSize: "0.7rem" }} + /> + )} + {(editorStatus.kind === "done" || uploadStatus.kind === "done") && ( + } + label="Done" + color="success" + size="small" + sx={{ height: 20, fontSize: "0.7rem" }} + /> + )} + + + {/* Log-style output */} + {editorStatus.kind === "idle" && uploadStatus.kind === "idle" && ( +
$ waiting for submission...
+ )} + + {(editorStatus.kind === "submitting" || uploadStatus.kind === "submitting") && ( +
Submitting code...
+ )} + + {editorStatus.kind === "polling" && ( + +
+ Submission #{editorStatus.submissionId} started +
+ {editorStatus.result?.runs?.map((run, i) => ( + +
+ [{run.mode.toUpperCase()}]{" "} + + {run.passed ? "✓ PASSED" : "✗ FAILED"} + + {run.score != null && ( + + {" "} + (score: {run.score.toFixed(2)}) + + )} +
+ {run.meta && Object.keys(run.meta).length > 0 && ( + + {Object.entries(run.meta).map(([key, value]) => ( +
+ {key}: {typeof value === "object" ? JSON.stringify(value) : String(value)} +
+ ))} +
+ )} + {run.report && Object.keys(run.report).length > 0 && ( + + {Object.entries(run.report).map(([key, value]) => ( +
+ {key}: {typeof value === "object" ? JSON.stringify(value) : String(value)} +
+ ))} +
+ )} +
+ ))} +
+ + Running... +
+
+ )} + + {editorStatus.kind === "done" && editorStatus.result && ( + +
+ Submission #{editorStatus.submissionId} completed +
+ {editorStatus.result.error && ( +
[ERROR] {editorStatus.result.error}
+ )} + {editorStatus.result.runs?.map((run, i) => ( + +
+ [{run.mode.toUpperCase()}]{" "} + + {run.passed ? "✓ PASSED" : "✗ FAILED"} + + {run.score != null && ( + + {" "} + (score: {run.score.toFixed(2)}) + + )} +
+ {run.meta && Object.keys(run.meta).length > 0 && ( + + {Object.entries(run.meta).map(([key, value]) => ( +
+ {key}: {typeof value === "object" ? JSON.stringify(value) : String(value)} +
+ ))} +
+ )} + {run.report && Object.keys(run.report).length > 0 && ( + + {Object.entries(run.report).map(([key, value]) => ( +
+ {key}: {typeof value === "object" ? JSON.stringify(value) : String(value)} +
+ ))} +
+ )} +
+ ))} +
$ Done
+
+ )} + + {editorStatus.kind === "error" && ( +
[ERROR] {editorStatus.msg}
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/leaderboard/components/editor/ResizableSplitPanel.tsx b/frontend/src/pages/leaderboard/components/editor/ResizableSplitPanel.tsx new file mode 100644 index 0000000..f7946b0 --- /dev/null +++ b/frontend/src/pages/leaderboard/components/editor/ResizableSplitPanel.tsx @@ -0,0 +1,153 @@ +import { Box } from "@mui/material"; +import { useRef, useState, useCallback, useEffect, type ReactNode } from "react"; + +interface ResizableSplitPanelProps { + topPanel: ReactNode; + bottomPanel: ReactNode; + middleContent?: ReactNode; + initialRatio?: number; + minRatio?: number; + maxRatio?: number; + height?: string; + minHeight?: number; +} + +export function ResizableSplitPanel({ + topPanel, + bottomPanel, + middleContent, + initialRatio = 0.6, + minRatio = 0.2, + maxRatio = 0.8, + height = "calc(100vh - 280px)", + minHeight = 400, +}: ResizableSplitPanelProps) { + const [ratio, setRatio] = useState(initialRatio); + const [isResizing, setIsResizing] = useState(false); + const panelRef = useRef(null); + const startY = useRef(0); + const startRatio = useRef(0); + + const handleResizeStart = useCallback( + (clientY: number) => { + startY.current = clientY; + startRatio.current = ratio; + setIsResizing(true); + }, + [ratio] + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + handleResizeStart(e.clientY); + }, + [handleResizeStart] + ); + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + if (e.touches.length === 1) { + handleResizeStart(e.touches[0].clientY); + } + }, + [handleResizeStart] + ); + + useEffect(() => { + let rafId: number | null = null; + + const handleMove = (clientY: number) => { + if (!isResizing || !panelRef.current) return; + + if (rafId) cancelAnimationFrame(rafId); + + rafId = requestAnimationFrame(() => { + if (!panelRef.current) return; + const panelRect = panelRef.current.getBoundingClientRect(); + const deltaY = clientY - startY.current; + const deltaRatio = deltaY / panelRect.height; + const newRatio = startRatio.current + deltaRatio; + setRatio(Math.max(minRatio, Math.min(maxRatio, newRatio))); + }); + }; + + const handleMouseMove = (e: MouseEvent) => { + handleMove(e.clientY); + }; + + const handleTouchMove = (e: TouchEvent) => { + if (e.touches.length === 1) { + handleMove(e.touches[0].clientY); + } + }; + + const handleEnd = () => { + if (rafId) cancelAnimationFrame(rafId); + setIsResizing(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + if (isResizing) { + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleEnd); + document.addEventListener("touchmove", handleTouchMove, { passive: true }); + document.addEventListener("touchend", handleEnd); + document.addEventListener("touchcancel", handleEnd); + } + + return () => { + if (rafId) cancelAnimationFrame(rafId); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleEnd); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleEnd); + document.removeEventListener("touchcancel", handleEnd); + }; + }, [isResizing, minRatio, maxRatio]); + + return ( + + {/* Top panel (Editor) */} + {topPanel} + + {/* Middle content (Controls) + Resize handle */} + + {middleContent} + + + + + + {/* Bottom panel (Output) */} + + {bottomPanel} + + + ); +} diff --git a/frontend/src/pages/leaderboard/components/editor/index.ts b/frontend/src/pages/leaderboard/components/editor/index.ts new file mode 100644 index 0000000..5e7621a --- /dev/null +++ b/frontend/src/pages/leaderboard/components/editor/index.ts @@ -0,0 +1,6 @@ +export { CodeEditorPanel } from "./CodeEditorPanel"; +export { JobOutputPanel } from "./JobOutputPanel"; +export { EditorControls } from "./EditorControls"; +export { ChallengeDescriptionPanel } from "./ChallengeDescriptionPanel"; +export { ResizableSplitPanel } from "./ResizableSplitPanel"; +export { editorStyles, type SubmitStatus } from "./types"; diff --git a/frontend/src/pages/leaderboard/components/editor/types.ts b/frontend/src/pages/leaderboard/components/editor/types.ts new file mode 100644 index 0000000..0f358be --- /dev/null +++ b/frontend/src/pages/leaderboard/components/editor/types.ts @@ -0,0 +1,78 @@ +import { type SubmissionStatusResponse } from "../../../../api/api"; + +export type SubmitStatus = + | { kind: "idle" } + | { kind: "submitting" } + | { kind: "polling"; submissionId: number; result?: SubmissionStatusResponse } + | { kind: "done"; submissionId: number; result: SubmissionStatusResponse } + | { kind: "error"; msg: string } + | { kind: "warning"; msg: string }; + +export const editorStyles = { + root: { + py: 3, + }, + header: { + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + mb: 2, + }, + editorCard: { + mb: 2, + }, + editorWrapper: { + border: "1px solid", + borderColor: "divider", + borderRadius: 1, + overflow: "hidden", + }, + controlsRow: { + display: "flex", + alignItems: "center", + gap: 2, + flexWrap: "wrap", + }, + submitBtn: { + borderRadius: 2, + px: 3, + py: 1, + fontWeight: "bold", + textTransform: "none", + background: "linear-gradient(90deg, #10b981 0%, #059669 100%)", + "&:hover": { + background: "linear-gradient(90deg, #059669 0%, #047857 100%)", + }, + }, + historyBtn: { + borderRadius: 2, + textTransform: "none", + }, + statusCard: { + mt: 2, + }, + statusHeader: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + mb: 1, + }, + uploadArea: { + border: "2px dashed", + borderColor: "divider", + borderRadius: 2, + p: 4, + textAlign: "center", + cursor: "pointer", + transition: "all 0.2s", + "&:hover": { + borderColor: "primary.main", + bgcolor: "action.hover", + }, + }, + uploadAreaDragging: { + borderColor: "primary.main", + bgcolor: "action.hover", + transform: "scale(1.01)", + }, +} as const; diff --git a/kernelboard/api/__init__.py b/kernelboard/api/__init__.py index 0acf139..7b3fcee 100644 --- a/kernelboard/api/__init__.py +++ b/kernelboard/api/__init__.py @@ -66,4 +66,5 @@ def get_about(): api.register_blueprint(auth_bp) api.register_blueprint(submission_bp) api.register_blueprint(events_bp) + return api diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index 3e2520b..df2e338 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -32,22 +32,27 @@ "submission_mode", ] - WEB_AUTH_HEADER = "X-Web-Auth-Id" MAX_CONTENT_LENGTH = 1 * 1024 * 1024 # 1MB max file size -# This blocks the leaderboard to show all the ranking codes when the leaderboard is ended, since -# some of those competition codes can be affect the upcoming competition results +# This blocks the leaderboard to show all the ranking codes when the leaderboard is ended BLOCKED_CODE_LEADERBOARD_LIST: list[str] = ["598"] # leaderboard id to block show + +USE_MOCK_SUBMISSION: bool = os.environ.get( + "USE_MOCK_SUBMISSION", "" +).lower() == "true" +MOCK_FAILURE_MODE: str | None = os.environ.get("MOCK_FAILURE_MODE") or None +# ============================================================================ + + @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 + exempt_when=lambda: not current_user.is_authenticated, ) def submission(): - # make sure user is logged in logger.info("submission received") user_id, username = get_id_and_username_from_session() log_rate_limit() @@ -86,6 +91,23 @@ def submission(): gpu_type = request.form.get("gpu_type") submission_mode = request.form.get("submission_mode") leaderboard_name = request.form.get("leaderboard") + + # DEV: Use mock submission (writes directly to local DB) + if USE_MOCK_SUBMISSION: + logging.warning("[!MOCK DATA!]USE_MOCK_SUBMISSION is on! this should only be used in dev mode!") + from kernelboard.lib.mocks.mock_submission import create_mock_submission + files = {"file": (filename, f.stream, mime)} + return create_mock_submission( + user_id=str(user_id), + leaderboard_name=leaderboard_name, + file_name=filename, + files=files, + submission_mode=submission_mode, + failure_mode=MOCK_FAILURE_MODE, + ) + else: + logger.info(f"USE_MOCK_SUBMISSION {USE_MOCK_SUBMISSION}send submission request to cluster-management api") + base = get_cluster_manager_endpoint() url = f"{base}/submission/{leaderboard_name}/{gpu_type}/{submission_mode}" files = { diff --git a/kernelboard/lib/mocks/__init__.py b/kernelboard/lib/mocks/__init__.py new file mode 100644 index 0000000..54d1113 --- /dev/null +++ b/kernelboard/lib/mocks/__init__.py @@ -0,0 +1 @@ +# Mock modules for local development diff --git a/kernelboard/lib/mocks/mock_submission.py b/kernelboard/lib/mocks/mock_submission.py new file mode 100644 index 0000000..b222e70 --- /dev/null +++ b/kernelboard/lib/mocks/mock_submission.py @@ -0,0 +1,503 @@ +""" +Mock submission for local development and testing. + +This simulates the real submission flow from discord-cluster-manager: +1. Create submission in DB (with code_files entry) +2. Create job status entry +3. After 10s: Create "test" run +4. After 20s: Create "leaderboard" run, mark done + +Usage: + 1. Set USE_MOCK_SUBMISSION = True in submission.py + 2. Submit code normally - it will use mock instead of real API +""" + +import json +import logging +import os +import random +import threading +import time +from datetime import datetime, timezone +from typing import Optional + +import psycopg2 + +from kernelboard.lib.status_code import http_error, http_success + +logger = logging.getLogger(__name__) + + +def _get_standalone_db_connection(): + """ + Get a standalone DB connection for background threads. + This doesn't use Flask's g object, so it works outside request context. + """ + database_url = os.environ.get("DATABASE_URL") + if not database_url: + raise RuntimeError("DATABASE_URL is not set") + return psycopg2.connect(database_url) + + +def _get_leaderboard_id(leaderboard_name: str) -> int: + """Get leaderboard ID from name.""" + conn = _get_standalone_db_connection() + cur = conn.cursor() + + try: + cur.execute( + """ + SELECT id FROM leaderboard.leaderboard WHERE name = %s + """, + (leaderboard_name,), + ) + result = cur.fetchone() + if result: + return result[0] + # Default to 1 if not found + return 1 + finally: + cur.close() + conn.close() + + +def _create_submission( + leaderboard_id: int, + user_id: str, + file_name: str, + code: str = "# mock submission code", +) -> int: + """ + Create a submission record following discord-cluster-manager pattern. + """ + conn = _get_standalone_db_connection() + cur = conn.cursor() + + try: + # 1. Check if code already exists (by hash) + cur.execute( + """ + SELECT id FROM leaderboard.code_files + WHERE hash = encode(sha256(%s::bytea), 'hex') + """, + (code.encode("utf-8"),), + ) + result = cur.fetchone() + + if result: + code_id = result[0] + else: + # Insert new code + cur.execute( + """ + INSERT INTO leaderboard.code_files (code) + VALUES (%s) + RETURNING id + """, + (code.encode("utf-8"),), + ) + code_id = cur.fetchone()[0] + + # 2. Create submission + cur.execute( + """ + INSERT INTO leaderboard.submission + (leaderboard_id, file_name, user_id, code_id, submission_time, done) + VALUES (%s, %s, %s, %s, %s, false) + RETURNING id + """, + (leaderboard_id, file_name, user_id, code_id, datetime.now(timezone.utc)), + ) + submission_id = cur.fetchone()[0] + + # 3. Create job status entry (pending) + cur.execute( + """ + INSERT INTO leaderboard.submission_job_status + (submission_id, status, created_at, last_heartbeat) + VALUES (%s, %s, %s, %s) + ON CONFLICT (submission_id) DO UPDATE + SET status = EXCLUDED.status, last_heartbeat = EXCLUDED.last_heartbeat + """, + (submission_id, "pending", datetime.now(timezone.utc), datetime.now(timezone.utc)), + ) + + conn.commit() + return submission_id + + finally: + cur.close() + conn.close() + + +def _upsert_job_status( + submission_id: int, + status: str, + error: Optional[str] = None, +): + """ + Update job status following discord-cluster-manager pattern. + """ + conn = _get_standalone_db_connection() + cur = conn.cursor() + + try: + cur.execute( + """ + INSERT INTO leaderboard.submission_job_status AS s + (submission_id, status, error, last_heartbeat) + VALUES (%s, %s, %s, %s) + ON CONFLICT (submission_id) DO UPDATE + SET + status = COALESCE(EXCLUDED.status, s.status), + error = COALESCE(EXCLUDED.error, s.error), + last_heartbeat = EXCLUDED.last_heartbeat + """, + (submission_id, status, error, datetime.now(timezone.utc)), + ) + conn.commit() + finally: + cur.close() + conn.close() + + +def _create_run( + submission_id: int, + mode: str, + runner: str, + passed: bool, + score: Optional[float] = None, + duration: float = 5.0, + stderr: Optional[str] = None, + stdout: Optional[str] = None, +): + """ + Create a run record following discord-cluster-manager pattern. + + meta contains: stdout, stderr, success, exit_code, command, duration + result contains: benchmark results (format that toReport can parse) + system_info contains: gpu info + """ + conn = _get_standalone_db_connection() + cur = conn.cursor() + + try: + start_time = datetime.now(timezone.utc) + end_time = start_time + + # meta follows discord-cluster-manager format + meta = { + "stdout": stdout or "", + "stderr": stderr or "", + "success": passed, + "exit_code": 0 if passed else 1, + "command": "mock_eval.py", + "duration": duration, + } + + # result format matches what toReport() expects + if mode == "test": + if passed: + result = { + "test.0.status": "pass", + "test.0.spec": "test_correctness", + "test.0.message": "All tests passed!", + "test.1.status": "pass", + "test.1.spec": "test_performance", + "test.1.message": "Performance within acceptable range.", + } + else: + result = { + "test.0.status": "fail", + "test.0.spec": "test_correctness", + "test.0.error": stderr or "Test failed", + } + elif mode == "benchmark": + if passed: + mean_ns = random.uniform(100000, 500000) * 1000 + err_ns = mean_ns * 0.05 + result = { + "benchmark-count": 1, + "benchmark.0.spec": "benchmark_kernel", + "benchmark.0.mean": mean_ns, + "benchmark.0.err": err_ns, + "benchmark.0.best": mean_ns * 0.95, + "benchmark.0.worst": mean_ns * 1.05, + } + else: + result = { + "benchmark-count": 1, + "benchmark.0.status": "fail", + "benchmark.0.spec": "benchmark_kernel", + "benchmark.0.error": stderr or "Benchmark failed", + } + elif mode == "leaderboard" and passed and score: + err_ns = score * 0.05 + result = { + "benchmark-count": 1, + "benchmark.0.spec": "leaderboard_kernel", + "benchmark.0.mean": score, + "benchmark.0.err": err_ns, + "benchmark.0.best": score * 0.95, + "benchmark.0.worst": score * 1.05, + } + else: + result = {} + + # system_info + system_info = { + "gpu": runner, + "driver_version": "mock-driver-535.104.05", + "cuda_version": "12.2", + } + + cur.execute( + """ + INSERT INTO leaderboard.runs + (submission_id, start_time, end_time, mode, secret, runner, + score, passed, compilation, meta, result, system_info) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + submission_id, + start_time, + end_time, + mode, + False, # secret + runner, + score, + passed, + None, # compilation + json.dumps(meta), + json.dumps(result), + json.dumps(system_info), + ), + ) + conn.commit() + finally: + cur.close() + conn.close() + + +def _mark_submission_done(submission_id: int): + """Mark submission as done.""" + conn = _get_standalone_db_connection() + cur = conn.cursor() + + try: + cur.execute( + """ + UPDATE leaderboard.submission + SET done = true + WHERE id = %s + """, + (submission_id,), + ) + conn.commit() + finally: + cur.close() + conn.close() + + +def _simulate_submission_flow( + submission_id: int, + submission_mode: str = "leaderboard", + runner: str = "NVIDIA A100", + simulate_test_failure: bool = False, + simulate_benchmark_failure: bool = False, +): + """ + Background thread that simulates submission flow based on submission_mode: + + - "test": Only runs test phase + - "leaderboard": Runs test → benchmark → leaderboard (with score) + """ + try: + # Phase 1: Running + logger.info( + "[MOCK] Submission %s: Starting (mode=%s)...", + submission_id, submission_mode + ) + _upsert_job_status(submission_id, "running") + time.sleep(10) + + # Phase 2: Test run (always runs first) + test_passed = not simulate_test_failure + test_stderr = None + if not test_passed: + test_stderr = ( + "Traceback (most recent call last):\n" + ' File "/root/eval.py", line 14, in \n' + " from utils import set_seed\n" + "ImportError: cannot import name 'set_seed' from 'utils'\n" + ) + + logger.info( + "[MOCK] Submission %s: Creating test run (passed=%s)", + submission_id, test_passed + ) + _create_run( + submission_id=submission_id, + mode="test", + runner=runner, + passed=test_passed, + score=None, + duration=random.uniform(1.0, 3.0), + stderr=test_stderr, + stdout="Running tests..." if test_passed else "", + ) + + if not test_passed: + _upsert_job_status(submission_id, "failed", "Test phase failed") + _mark_submission_done(submission_id) + logger.info("[MOCK] Submission %s: Finished (test failed)", submission_id) + return + + # For "test" mode, we're done after test passes + if submission_mode == "test": + _upsert_job_status(submission_id, "succeeded") + _mark_submission_done(submission_id) + logger.info("[MOCK] Submission %s: Finished (test mode)", submission_id) + return + + # Phase 3: Benchmark run (for leaderboard mode) + logger.info("[MOCK] Submission %s: Starting benchmark phase...", submission_id) + time.sleep(10) + + benchmark_passed = not simulate_benchmark_failure + benchmark_stderr = None + if not benchmark_passed: + benchmark_stderr = "RuntimeError: CUDA out of memory" + + logger.info( + "[MOCK] Submission %s: Creating benchmark run (passed=%s)", + submission_id, benchmark_passed + ) + _create_run( + submission_id=submission_id, + mode="benchmark", + runner=runner, + passed=benchmark_passed, + score=None, + duration=random.uniform(3.0, 8.0), + stderr=benchmark_stderr, + stdout="Benchmark completed." if benchmark_passed else "", + ) + + if not benchmark_passed: + _upsert_job_status(submission_id, "failed", "Benchmark phase failed") + _mark_submission_done(submission_id) + logger.info("[MOCK] Submission %s: Finished (benchmark failed)", submission_id) + return + + # Phase 4: Leaderboard run (with score) + logger.info("[MOCK] Submission %s: Creating leaderboard run...", submission_id) + time.sleep(5) + + # Generate score in nanoseconds (like real backend) + score_ns = random.uniform(100000, 500000) * 1000 + + _create_run( + submission_id=submission_id, + mode="leaderboard", + runner=runner, + passed=True, + score=score_ns, + duration=random.uniform(3.0, 8.0), + stderr=None, + stdout="Leaderboard run completed.", + ) + + # Mark as done + _upsert_job_status(submission_id, "succeeded") + _mark_submission_done(submission_id) + + logger.info("[MOCK] Submission %s: Finished (succeeded)", submission_id) + + except Exception as e: + logger.error("[MOCK] Submission %s error: %s", submission_id, e) + _upsert_job_status(submission_id, "failed", str(e)) + _mark_submission_done(submission_id) + + +def create_mock_submission( + user_id: str, + leaderboard_name: str, + file_name: str, + files: dict, + submission_mode: str = "leaderboard", + failure_mode: str = None, +): + """ + Called by submission.py when USE_MOCK_SUBMISSION = True. + Mimics the real cluster API: returns submission_id immediately, + then runs simulation in background. + + submission_mode: + - "test": Only runs test phase + - "leaderboard": Runs test → benchmark → leaderboard (with score) + + failure_mode: + - None: All phases pass + - "test": Test phase fails + - "benchmark": Test passes, benchmark fails + """ + try: + # Get leaderboard_id from name + lb_id = _get_leaderboard_id(leaderboard_name) if leaderboard_name else 1 + + # Extract file content from files dict + # files = {"file": (filename, fileobj, content_type)} + file_content = "# mock submission code" + if files and "file" in files: + file_tuple = files["file"] + if len(file_tuple) >= 2: + fileobj = file_tuple[1] + fileobj.seek(0) + file_content = fileobj.read() + if isinstance(file_content, bytes): + file_content = file_content.decode("utf-8") + + submission_id = _create_submission( + leaderboard_id=lb_id, + user_id=user_id, + file_name=file_name or "mock.py", + code=file_content, + ) + + logger.info( + "[MOCK] Created submission %s (mode=%s, failure=%s) for user %s", + submission_id, submission_mode, failure_mode, user_id + ) + + # Determine failure flags + simulate_test_failure = failure_mode == "test" + simulate_benchmark_failure = failure_mode == "benchmark" + + # Background thread simulates the job flow + thread = threading.Thread( + target=_simulate_submission_flow, + args=( + submission_id, + submission_mode, + "NVIDIA A100", + simulate_test_failure, + simulate_benchmark_failure, + ), + daemon=True, + ) + thread.start() + + # Return same format as real cluster API + return http_success( + message="submission success, please refresh submission history", + data={"submission_id": submission_id}, + ) + + except Exception as e: + logger.error("[MOCK] Failed to create submission: %s", e) + return http_error( + message=str(e), + status_code=500, + )