diff --git a/README.md b/README.md index 91854feb..df2e2eeb 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_URL 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/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/api/api.ts b/frontend/src/api/api.ts index 0f6088fc..30182609 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -73,3 +73,49 @@ export async function logout(): Promise { const r = await res.json(); return r.data; } + +export async function submitFile(form: FormData) { + 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; +} 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/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/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/lib/date/utils.ts b/frontend/src/lib/date/utils.ts index e378c4ad..cd6f97bf 100644 --- a/frontend/src/lib/date/utils.ts +++ b/frontend/src/lib/date/utils.ts @@ -31,3 +31,20 @@ export const getTimeLeft = (deadline: string): string => { return `${days} ${dayLabel} ${hours} ${hourLabel} remaining`; }; + +export const isExpired = ( + deadline: string | Date, + time: Date = new Date(), +): boolean => { + let d: Date; + if (typeof deadline === "string") { + const parsed = new Date(deadline); + if (isNaN(parsed.getTime())) { + return true; + } + d = parsed; + } else { + d = deadline; + } + return d.getTime() <= time.getTime(); +}; 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.test.tsx b/frontend/src/pages/leaderboard/Leaderboard.test.tsx index 1540c977..52bb4576 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.test.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.test.tsx @@ -1,22 +1,50 @@ 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 mockExpiredDeadline = "2024-01-29T12:00:00-02:00"; +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(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); // freeze "now" + 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 +55,462 @@ 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 -------------------- + + it("show reference codeblock (after switching to Reference tab)", () => { const mockData = { - name: "test-empty", - description: " ", + name: "test-code", + description: "", deadline: "", gpu_types: ["T1"], - referece: "", + reference: mockReference, + rankings: { T1: [] }, + }; + + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ + data: mockData, + loading: false, + error: null, + errorStatus: null, + call: mockCall, + }); + + renderWithRouter(); + + // Must switch to Reference tab first + fireEvent.click(screen.getByRole("tab", { name: /Reference/i })); + + // Reference codeblock should be visible + expect(screen.getByText(/Reference Implementation/i)).toBeInTheDocument(); + expect(screen.getByText(mockReference)).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: [], + T1: [ + { + file_name: "test.py", + prev_score: 0.1, + rank: 1, + score: 3.25, + user_name: "user1", + }, + ], }, }; - const mockHookReturn = { + (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: mockDeadline, + 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: mockDeadline, + 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(); + expect( + screen.getByTestId("submission-history-section"), + ).toBeInTheDocument(); + }); + + it("shows Submit button when deadline is in the future", () => { + currentAuth = { me: { authenticated: true, user: { identity: "u-1" } } }; + + const mockData = { + name: "lb-auth", + description: "", + deadline: mockDeadline, + 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 })); + + const submit_btn = screen.getByTestId("leaderboard-submit-btn"); + expect(submit_btn).toBeInTheDocument(); + expect(submit_btn).not.toBeDisabled(); + + expect( + screen.queryByTestId("deadline-passed-text"), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId("submission-history-section"), + ).toBeInTheDocument(); + }); + + it("shows expired message when deadline is in the past", () => { + currentAuth = { me: { authenticated: true, user: { identity: "u-1" } } }; + + const mockData = { + name: "lb-auth", + description: "", + deadline: mockExpiredDeadline, + gpu_types: ["T1"], + reference: mockReference, + rankings: { T1: [] }, }; - (apiHook.fetcherApiCallback as ReturnType).mockReturnValue( - mockHookReturn, - ); + (apiHook.fetcherApiCallback as ReturnType).mockReturnValue({ + data: mockData, + loading: false, + error: null, + errorStatus: null, + call: mockCall, + }); - // render renderWithRouter(); - // asserts - const toggle = screen.getByTestId("codeblock-show-all-toggle"); - expect(within(toggle).getByText(/show more/i)).toBeInTheDocument(); + // Switch to the Submission tab explicitly + fireEvent.click(screen.getByRole("tab", { name: /Submission/i })); - // Click to expand - fireEvent.click(toggle); - expect(within(toggle).getByText(/hide/i)).toBeInTheDocument(); + const submit_btn = screen.getByTestId("leaderboard-submit-btn"); + expect(submit_btn).toBeInTheDocument(); + expect(submit_btn).toBeDisabled(); - // Click to collapse again - fireEvent.click(toggle); - expect(within(toggle).getByText(/show more/i)).toBeInTheDocument(); + const deadline_txt = screen.getByTestId("deadline-passed-text"); + expect( + within(deadline_txt).getByText(/deadline has passed/i), + ).toBeInTheDocument(); + expect( + screen.getByTestId("submission-history-section"), + ).toBeInTheDocument(); }); }); diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index 7fd69fd6..ff20fc80 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -1,67 +1,118 @@ -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 { isExpired, 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 SubmissionHistorySection from "./components/submission-history/SubmissionHistorySection"; +import LeaderboardSubmit from "./components/LeaderboardSubmit"; +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; + + // 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 ; - } + if (loading) return ; + if (error) return ; - // handles specific error - if (error) { - return ; - } + const descriptionText = (text: string) => ( + + {text} + + ); - const descriptionText = (text: string) => { - return ( - - {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 +133,102 @@ export default function Leaderboard() { - - - Reference Implementation - - - - - - - + + {/* Tab navigation */} + + setTab(v)} + aria-label="Leaderboard Tabs" + variant="scrollable" + scrollButtons + allowScrollButtonsMobile + > + + + + + + {/* Ranking Tab */} + + + {Object.entries(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
+ ) : ( + + + {/* Header Row */} + + Submission + + + {/* Deadline Passed Message */} + {isExpired(data.deadline) && ( + + + The submission deadline has passed. You can no longer + submit. + + + But don't worry — we have more leaderboards! + + + )} + {/* History List */} + + + + )} +
); 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..14d76ba2 --- /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 \(> 1 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/LeaderboardSubmit.tsx b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx new file mode 100644 index 00000000..c0e14b22 --- /dev/null +++ b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx @@ -0,0 +1,273 @@ +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"; +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 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 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: LeaderboardSubmit + * Parent provides only: leaderboardId, leaderboardName, gpuTypes, modes + */ +export default function LeaderboardSubmit({ + leaderboardId, + leaderboardName, + gpuTypes, + modes, + disabled = false, +}: { + leaderboardId: string; + leaderboardName: string; + gpuTypes: string[]; + modes: string[]; + disabled?: boolean; +}) { + 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 [sucessAlert, setSuccessAlert] = useState(false); + 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 { + // set a max file size too + 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)`; + 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(); + }, 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()} + { + 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} + + )} +
+
+ + + + +
+ + ); +} diff --git a/frontend/src/pages/leaderboard/components/submission-history/SubmissionDoneCell.tsx b/frontend/src/pages/leaderboard/components/submission-history/SubmissionDoneCell.tsx new file mode 100644 index 00000000..08f3ccad --- /dev/null +++ b/frontend/src/pages/leaderboard/components/submission-history/SubmissionDoneCell.tsx @@ -0,0 +1,17 @@ +import { Tooltip } from "@mui/material"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty"; + +function SubmissionDoneCell({ done }: { done: boolean }) { + return done ? ( + + + + ) : ( + + + + ); +} + +export default SubmissionDoneCell; diff --git a/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx new file mode 100644 index 00000000..713b54b4 --- /dev/null +++ b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx @@ -0,0 +1,199 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Box, + Typography, + IconButton, + Alert, + Tooltip, + TableContainer, + Table, + TableHead, + TableRow, + TableCell, + TableBody, + TablePagination, +} from "@mui/material"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { fetchUserSubmissions } from "../../../../api/api"; +import { fetcherApiCallback } from "../../../../lib/hooks/useApi"; +import SubmissionStatusChip from "./SubmissionStatusChip"; +import SubmissionDoneCell from "./SubmissionDoneCell"; +import Loading from "../../../../components/common/loading"; + +type Submission = { + submission_id: number; + file_name?: string | null; + submitted_at: string; // ISO + status?: string | null; + submission_done: boolean; +}; + +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, + }, + listWrapper: { + flex: 1, + overflowY: "auto", + mt: 1, + }, + footer: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + mt: 1, + }, +}; + +export default function SubmissionHistorySection({ + 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; + + // 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]); + + 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} + + )} + + {/* Table */} + + {!loading && !error && items.length === 0 && ( + + No submissions. + + )} + + {!loading && !error && items.length > 0 && ( + + + + + File + Submitted At + Status + + Finished + + + + + {items.map((s) => ( + + + {s.file_name || `Submission #${s.submission_id}`} + + + {new Date(s.submitted_at).toLocaleString()} + + + + + + + + + ))} + +
+
+ )} +
+ + {/* Footer: pagination + range */} + + + {showingRange} + + !loading && setPage(p0 + 1)} + rowsPerPage={pageSize} + onRowsPerPageChange={() => {}} + rowsPerPageOptions={[pageSize]} + showFirstButton + showLastButton + disabled={loading || total <= pageSize} + /> + +
+ ); +} diff --git a/frontend/src/pages/leaderboard/components/submission-history/SubmissionStatusChip.tsx b/frontend/src/pages/leaderboard/components/submission-history/SubmissionStatusChip.tsx new file mode 100644 index 00000000..497df013 --- /dev/null +++ b/frontend/src/pages/leaderboard/components/submission-history/SubmissionStatusChip.tsx @@ -0,0 +1,35 @@ +import { Chip, Tooltip } from "@mui/material"; + +function SubmissionStatusChip({ status }: { status?: string | null }) { + const str = typeof status === "string" ? status : ""; // normalize + const v = str.toLowerCase(); + + const color: "default" | "success" | "warning" | "error" = v.includes("run") + ? "warning" + : v.includes("ok") || v.includes("succ") + ? "success" + : v.includes("fail") || v.includes("err") + ? "error" + : "default"; + + const showFallback = !str; + const label = showFallback ? "submitted via CLI/Discord bot" : str; + + // When no tooltip needed, pass undefined (not empty string) to avoid disabling issues. + const title = showFallback + ? "No job status recorded; likely submitted through the CLI or Discord bot (not the web UI)." + : undefined; + + return ( + + + + ); +} + +export default SubmissionStatusChip; 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/__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 ec4c084b..7be944f7 100644 --- a/kernelboard/api/auth.py +++ b/kernelboard/api/auth.py @@ -7,15 +7,15 @@ import requests from flask import ( Blueprint, - abort, current_app as app, - jsonify, redirect, request, session, 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__) @@ -135,6 +135,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") @@ -189,7 +190,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" @@ -234,15 +235,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_auth_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 +266,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/leaderboard.py b/kernelboard/api/leaderboard.py index 19ee337b..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 @@ -33,7 +31,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/kernelboard/api/news.py b/kernelboard/api/news.py index 07e5de2b..e65b9ce2 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 @@ -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 new file mode 100644 index 00000000..7b6a558a --- /dev/null +++ b/kernelboard/api/submission.py @@ -0,0 +1,265 @@ +import http +from typing import Any, List, Optional, Tuple +from flask import Blueprint, request, jsonify +from flask_login import login_required, current_user +import requests +from kernelboard.lib.auth_utils import ( + get_id_and_username_from_session, +) +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 os +from kernelboard.lib.rate_limiter import limiter +import time + +logger = logging.getLogger(__name__) + +submission_bp = Blueprint("submission_bp", __name__) + +REQUIRED_SUBMISSION_REQUEST_FIELDS = [ + "leaderboard_id", + "leaderboard", + "gpu_type", + "submission_mode", +] + + +WEB_AUTH_HEADER = "X-Web-Auth-Id" +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") + user_id, username = get_id_and_username_from_session() + log_rate_limit() + + 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. 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) + 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, + status_code=e.status, + **e.extras, + ) + except Exception as e: + logger.error(f"Failed to get submission file info: {e}") + return http_error( + message=str(e), + 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") + submission_mode = request.form.get("submission_mode") + leaderboard_name = request.form.get("leaderboard") + base = get_cluster_manager_endpoint() + url = f"{base}/submission/{leaderboard_name}/{gpu_type}/{submission_mode}" + files = { + # requests expects (filename, fileobj, content_type) + "file": (filename, f.stream, mime), + } + headers = { + WEB_AUTH_HEADER: web_token, + } + + logger.info("send submission request to leaderboard") + 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 + + try: + payload = resp.json() + message = ( + payload.get("message") or payload.get("detail") or resp.reason + ) + if resp.status_code == 200: + return http_success( + message="submission success, please refresh submission history", + data=payload, + ) + else: + return http_error( + message=message, + status_code=http.HTTPStatus(resp.status_code), + data=payload, + ) + except Exception as e: + logger.error(f"faild to submit request: {e}") + return http_error( + message=f"{e}", + status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + +@submission_bp.route("/submissions", methods=["GET"]) +@login_required +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 + # submit request info: mode and gpu type + # this could be a followup to provide more information + logger.info("list submission request is received") + + 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) + + 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 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, + limit: int = 20, + offset: int = 0, +) -> Tuple[List[dict[str, Any]], int]: + conn = get_db_connection() + 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_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 + + +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/auth_utils.py b/kernelboard/lib/auth_utils.py new file mode 100644 index 00000000..575201e3 --- /dev/null +++ b/kernelboard/lib/auth_utils.py @@ -0,0 +1,92 @@ + +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 +import logging +logger = logging.getLogger(__name__) + +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_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() + 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..c1c542f4 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,17 @@ 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: + db.rollback() + logger.error("DB error, rolling back: %s", e) + + else: + db.commit() + finally: + db.close() 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) 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/error.py b/kernelboard/lib/error.py new file mode 100644 index 00000000..bbacbf0f --- /dev/null +++ b/kernelboard/lib/error.py @@ -0,0 +1,49 @@ +import http +from typing import List +import logging + +logger = logging.getLogger(__name__) + +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..794c3b4e --- /dev/null +++ b/kernelboard/lib/file_handler.py @@ -0,0 +1,86 @@ +import ast +import re +import mimetypes +from werkzeug.utils import secure_filename +from kernelboard.lib.error import InvalidMimeError, InvalidSyntaxError,InvalidPythonExtensionError,MissingRequiredFieldError + +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("missing required submission python file in requests.files, if this is unexpected, please contact the gpumode administrator") + + 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" + ) + + # Validate extension + ext = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if ext not in ALLOWED_EXTS: + raise InvalidPythonExtensionError() + + # Peek first 2KB for quick checks and MIME guessing + sample = f.stream.read(2048) + f.stream.seek(0) + + # 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 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") + 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/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/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/kernelboard/lib/time.py b/kernelboard/lib/time.py index 7c1a3e2a..8e3944ba 100644 --- a/kernelboard/lib/time.py +++ b/kernelboard/lib/time.py @@ -29,7 +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 format_datetime(dt: datetime | str) -> str: """ Common formatting for datetime objects. 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_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/api/test_submission_api.py b/tests/api/test_submission_api.py new file mode 100644 index 00000000..88244536 --- /dev/null +++ b/tests/api/test_submission_api.py @@ -0,0 +1,359 @@ +# tests/test_submission_api.py +import http +from io import BytesIO +from types import SimpleNamespace +from unittest.mock import patch, MagicMock +import flask_login +import datetime as dt +import requests +import pytest +from kernelboard.lib.db import get_db_connection +from psycopg2.extras import execute_values + +_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: + with conn.cursor() as cur: + cur.execute( + """ + DELETE FROM leaderboard.runs + WHERE submission_id IN ( + SELECT id FROM leaderboard.submission WHERE user_id = %s + ) + """, + (user_id,), + ) + cur.execute( + """ + DELETE FROM leaderboard.submission_job_status + WHERE submission_id IN ( + SELECT id FROM leaderboard.submission WHERE user_id = %s + ) + """, + (user_id,), + ) + cur.execute( + "DELETE FROM leaderboard.submission WHERE user_id = %s", + (user_id,), + ) + cur.execute( + "DELETE FROM leaderboard.user_info WHERE id = %s", + (user_id,), + ) + +@pytest.fixture +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.cursor() as cur: + # 1) user_info upsert + cur.execute( + """ + INSERT INTO leaderboard.user_info (id, user_name, web_auth_id, cli_id, cli_valid) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (id) DO UPDATE + SET user_name = EXCLUDED.user_name, + web_auth_id = EXCLUDED.web_auth_id, + cli_id = EXCLUDED.cli_id, + cli_valid = EXCLUDED.cli_valid + RETURNING id, user_name, web_auth_id, cli_valid + """, + (_TEST_USER_ID, "test-user", _TEST_WEB_AUTH_ID, None, True), + ) + + # 2) submissions upsert + RETURNING + submissions = [ + ("101", "339", _TEST_USER_ID, "1001", "solution_a.py", now), + ("102", "339", _TEST_USER_ID, "1002", "solution_b.py", now - dt.timedelta(days=1)), + ] + sub_sql = """ + INSERT INTO leaderboard.submission + (id, leaderboard_id, user_id, code_id, file_name, submission_time) + VALUES %s + ON CONFLICT (id) DO UPDATE + SET file_name = EXCLUDED.file_name, + submission_time = EXCLUDED.submission_time, + user_id = EXCLUDED.user_id, + leaderboard_id = EXCLUDED.leaderboard_id, + code_id = EXCLUDED.code_id + RETURNING id, leaderboard_id, user_id, code_id, file_name, submission_time + """ + sub_ret = execute_values(cur, sub_sql, submissions, fetch=True) + assert {r[0] for r in sub_ret} == {101, 102} + + # 3) job_status upsert + RETURNING + job_status = [ + (101, "running", None, None, now), + (102, "pending", None, None, now - dt.timedelta(days=1)), + ] + js_sql = """ + INSERT INTO leaderboard.submission_job_status + (submission_id, status, error, last_heartbeat, created_at) + VALUES %s + ON CONFLICT (submission_id) DO UPDATE + SET status = EXCLUDED.status, + error = EXCLUDED.error, + last_heartbeat = EXCLUDED.last_heartbeat, + created_at = EXCLUDED.created_at + RETURNING submission_id, status, created_at + """ + js_ret = execute_values(cur, js_sql, job_status, fetch=True) + assert dict((r[0], r[1]) for r in js_ret) == {101: "running", 102: "pending"} + + def _cleanup_user_graph(): + def _cleanup(): + with app.app_context(): + conn2 = get_db_connection() + _delete_user_graph(conn2, _TEST_USER_ID) + request.addfinalizer(_cleanup_user_graph) + +@pytest.fixture +def prepare(monkeypatch, app, request): + """ + Factory fixture: + prepare(auth=True, web_token=True) + + - Sets DISCORD_CLUSTER_MANAGER_API_BASE_URL (auto-reset via monkeypatch) + - If auth=True: upserts the test user, patches flask-login current user + - Registers a finalizer to DELETE the test user so the DB is clean after the test + """ + + def _prepare(auth: bool = True, web_token: bool = True): + # Auto-reset after each test + monkeypatch.setenv( + "DISCORD_CLUSTER_MANAGER_API_BASE_URL", + "http://0.0.0.0:8000", + ) + + if auth: + web_auth_id = _TEST_WEB_AUTH_ID if web_token else "" + with app.app_context(): + conn = get_db_connection() + with conn: # commit on success, rollback on exception + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO leaderboard.user_info + (id, user_name, web_auth_id, cli_id, cli_valid) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (id) DO UPDATE + SET web_auth_id = EXCLUDED.web_auth_id, + cli_id = EXCLUDED.cli_id, + cli_valid = EXCLUDED.cli_valid + """, + (_TEST_USER_ID, "alice", web_auth_id, 777, 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) + + # DB cleanup: remove the test user row after the test + def _cleanup(): + with app.app_context(): + conn2 = get_db_connection() + _delete_user_graph(conn2, _TEST_USER_ID) + request.addfinalizer(_cleanup) + else: + # Explicit anonymous user + anon = SimpleNamespace(is_anonymous=True,is_authenticated=False, get_id=lambda: None) + monkeypatch.setattr(flask_login.utils, "_get_user", lambda: anon) + return _prepare + +# Helper: always attaches a file; allow overrides +def _post_submission(client, form_overrides=None, file_tuple=None): + fields = { + "leaderboard_id": "1", + "leaderboard": "llama", + "gpu_type": "A100", + "submission_mode": "test", + } + if form_overrides: + fields.update(form_overrides) + + # (fileobj, filename, mimetype) — Werkzeug order + file_tuple = file_tuple or (BytesIO(b'print("ok")\n'), "solution.py", "text/x-python") + + return client.post( + "/api/submission", + data={**fields, "file": file_tuple}, + # NOTE: Flask/Werkzeug will infer multipart because a file tuple is present. + # If your stack needs it explicitly, uncomment the next line: + # content_type="multipart/form-data", + ) + + +def test_submission_happy_path(app, client, prepare): + # auth + web_token default to True + prepare() + + # fake response from external submission API + submission_response = MagicMock() + submission_response.status_code = 200 + submission_response.json.return_value = {"message": "queued", "job_id": "j_1"} + + with patch("kernelboard.api.submission.requests.post", return_value=submission_response) as mock_post: + resp = _post_submission(client) + + assert resp.status_code == http.HTTPStatus.OK + js = resp.get_json() + assert js["message"].lower().startswith("submission success") + assert js["data"]["job_id"] == "j_1" + + # assert external call was correct + mock_post.assert_called_once() + called_url = mock_post.call_args.args[0] + called_headers = mock_post.call_args.kwargs.get("headers") + assert called_url == "http://0.0.0.0:8000/submission/llama/A100/test" + # _TEST_WEB_AUTH_ID is "111" per your earlier code/comments + assert called_headers["X-Web-Auth-Id"] == "111" + + +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 + + +def test_submission_missing_web_token(app, client, prepare): + # Auth but missing/empty web token + prepare(web_token=False) + + resp = _post_submission(client) + assert resp.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR + assert "cannot find user info" in resp.get_json()["message"].lower() + + +def test_submission_validation_error(app, client, prepare): + prepare() + + base_form = { + "leaderboard_id": "1", + "leaderboard": "llama", + "gpu_type": "A100", + "submission_mode": "test", + } + + # Import from your module if available: + # from kernelboard.api.submission import REQUIRED_SUBMISSION_REQUEST_FIELDS + REQUIRED_SUBMISSION_REQUEST_FIELDS = ["file", "leaderboard_id", "gpu_type", "submission_mode"] + + for field in REQUIRED_SUBMISSION_REQUEST_FIELDS: + # copy and drop one field + form = {**base_form} + # If the missing field is 'file', send no file + if field == "file": + resp = client.post("/api/submission", data=form) + else: + form[field] = "" + resp = _post_submission(client, form_overrides=form) + + assert ( + resp.status_code == http.HTTPStatus.BAD_REQUEST + ), f"Missing {field} should return 400, but got {resp.status_code}" + + +def test_submission_file_invalid_file(app, client, prepare): + # Auth ok + prepare() + + fields = { + "leaderboard_id": "1", + "leaderboard": "llama", + "gpu_type": "A100", + "submission_mode": "test", + } + # Wrong filename/mime for your validator to reject + bad_file = (BytesIO(b'print("ok")\n'), "solution.text", "text/x-python") + + resp = client.post("/api/submission", data={**fields, "file": bad_file}) + assert resp.status_code == http.HTTPStatus.BAD_REQUEST + body = resp.get_json() + assert body and "invalid" in (body.get("message", "") + body.get("error", "")).lower() + + +def test_submission_forward_request_exception_returns_502(app, client, prepare): + prepare() + + with patch("kernelboard.api.submission.requests.post", side_effect=requests.RequestException("boom")): + resp = _post_submission(client) + + assert resp.status_code == http.HTTPStatus.BAD_GATEWAY # 502 + body = resp.get_json() + assert "forward failed" in body["error"].lower() + + +def test_submission_upstream_non_200_maps_to_http_error(app, client, prepare): + prepare() + + error_response = MagicMock() + error_response.status_code = 400 + error_response.reason = "Bad Request" + error_response.json.return_value = {"detail": "invalid format"} + + with patch("kernelboard.api.submission.requests.post", return_value=error_response) as mock_post: + resp = _post_submission(client) + + assert resp.status_code == http.HTTPStatus.BAD_REQUEST + js = resp.get_json() + assert js["code"] == 10000 + 400 + assert "invalid format" in js["message"].lower() + + +# ---------------------------- +# /api/submissions list tests +# ---------------------------- + +def test_list_submissions_requires_auth(app, client, prepare, seed_submissions): + prepare(auth=False) + resp = client.get("/api/submissions") + assert resp.status_code == http.HTTPStatus.UNAUTHORIZED + +def test_list_submissions_requires_leaderboard_id(app, client, prepare): + prepare() + resp = client.get("/api/submissions?limit=10&offset=0") + assert resp.status_code == http.HTTPStatus.BAD_REQUEST + assert "leaderboard_id" in resp.get_json()["message"] + + +def test_list_submissions_clamps_limit_happy_path(client,seed_submissions, prepare): + prepare() + r = client.get("/api/submissions?leaderboard_id=339&limit=999&offset=0") + assert r.status_code == http.HTTPStatus.OK, r.get_data(as_text=True) + js = r.get_json() + + # Clamp + totals + assert js["data"]["limit"] == 100 + assert js["data"]["total"] == 2 + + # Assuming ORDER BY submission_time DESC (latest first) + assert js["data"]["items"][0]["submission_id"] == 101 + assert js["data"]["items"][0]["status"] == "running" + assert js["data"]["items"][1]["submission_id"] == 102 + assert js["data"]["items"][1]["status"] == "pending" + + +def test_list_submissions_happy_path(client,seed_submissions, prepare): + prepare() + r = client.get("/api/submissions?leaderboard_id=339&limit=50&offset=0") + assert r.status_code == http.HTTPStatus.OK, r.get_data(as_text=True) + js = r.get_json() + + assert js["data"]["limit"] == 50 + assert js["data"]["total"] == 2 + + assert js["data"]["items"][0]["submission_id"] == 101 + assert js["data"]["items"][0]["status"] == "running" + assert js["data"]["items"][1]["submission_id"] == 102 + assert js["data"]["items"][1]["status"] == "pending" diff --git a/tests/conftest.py b/tests/conftest.py index 65b362cd..bbf37054 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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") diff --git a/tests/data.sql b/tests/data.sql index 76a4633c..88080121 100644 --- a/tests/data.sql +++ b/tests/data.sql @@ -9188,7 +9188,23 @@ 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, + ADD COLUMN IF NOT EXISTS cli_id VARCHAR(255) DEFAULT NULL, + ADD COLUMN IF NOT EXISTS cli_valid BOOLEAN DEFAULT FALSE; + +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 -- -