diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 173fd378..5a59f4dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,12 @@ jobs: - name: Install frontend dependencies run: cd frontend && npm ci + - name: Lint Python + run: ruff check kernelboard/ tests/ + + - name: Lint frontend + run: cd frontend && npm run lint + - name: Run Python tests run: pytest --tb=short continue-on-error: true diff --git a/CLAUDE.md b/CLAUDE.md index 05d82a79..8a8496ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,42 @@ Look for the `hackathons` array around line 87. To add a hackathon: Upcoming lectures are pulled live from Discord's scheduled events API (5-minute cache). Requires `DISCORD_BOT_TOKEN` and `DISCORD_GUILD_ID` environment variables. +## Database Access + +The production PostgreSQL database is hosted on Heroku under the `discord-cluster-manager` app: +- **Heroku Dashboard:** https://dashboard.heroku.com/apps/discord-cluster-manager +- **Connection:** Use `heroku pg:psql -a discord-cluster-manager` to connect interactively +- **Credentials:** Use `heroku pg:credentials:url -a discord-cluster-manager` to get the connection string +- **Schema:** All tables live under the `leaderboard` schema (e.g., `leaderboard.runs`, `leaderboard.submission`, `leaderboard.leaderboard`, `leaderboard.user_info`, `leaderboard.code_files`, `leaderboard.gpu_type`, `leaderboard.submission_job_status`) + +### Key Tables +- `leaderboard.leaderboard` - Competition definitions (name, deadline, task JSONB) +- `leaderboard.submission` - User submissions linked to code files +- `leaderboard.runs` - Individual run results with scores (lower is better), GPU type, pass/fail +- `leaderboard.user_info` - User accounts (Discord/Google/GitHub OAuth) +- `leaderboard.gpu_type` - GPU types supported per leaderboard +- `leaderboard.code_files` - Submitted code with SHA256 hash +- `leaderboard.submission_job_status` - Job tracking (pending/running/succeeded/failed/timed_out) + +### Ranking Logic +Rankings are computed via SQL window functions: +1. Best run per user per GPU type (lowest score wins, must be `passed=true`, `secret=false`, `score IS NOT NULL`) +2. Global rank via `RANK() OVER (PARTITION BY leaderboard_id, runner ORDER BY score ASC)` +3. GPU priority order: B200 > H100 > MI300 > A100 > L4 > T4 + +## Linting + +CI enforces linting for both Python and the frontend. PRs will fail if either linter reports errors. + +**Python (Ruff):** +- Check: `ruff check kernelboard/ tests/` +- Auto-fix: `ruff check --fix kernelboard/ tests/` +- Config: `pyproject.toml` under `[tool.ruff]` + +**Frontend (ESLint):** +- Check: `cd frontend && npm run lint` +- Auto-fix: `cd frontend && npm run lint -- --fix` + ## Project Structure - `kernelboard/` - Flask backend diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 8ce6333d..987a764e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -20,9 +20,15 @@ export default tseslint.config( rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': [ - 'warn', + 'error', { allowConstantExport: true }, ], + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }], 'eol-last': ['error', 'always'] }, }, diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 9209ff3e..56edc5f4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,5 +1,5 @@ import { describe, it } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import App from "./App"; diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 43e38cb3..586d0fab 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1,3 +1,5 @@ +import type { User } from "../lib/types/user"; + export class APIError extends Error { status: number; constructor(message: string, status: number) { @@ -16,7 +18,75 @@ export async function fetchAboutInfo(): Promise { return r.data.message; } -export async function fetchLeaderBoard(id: string): Promise { +export interface LeaderboardDetail { + deadline: string; + description: string; + name: string; + reference: string; + gpu_types: string[]; + rankings: Record< + string, + Array<{ + file_name: string; + prev_score: number; + rank: number; + score: number; + user_name: string; + submission_id: number; + }> + >; +} + +export interface CodesResponse { + results: Array<{ + submission_id: number; + code: string; + }>; +} + +export interface NewsPost { + id: string; + title: string; + content: string; + [key: string]: unknown; +} + +export interface LeaderboardSummary { + id: number; + name: string; + deadline: string; + gpu_types: string[]; + priority_gpu_type: string; + top_users: Array<{ rank: number; score: number; user_name: string }> | null; +} + +export interface LeaderboardSummariesResponse { + leaderboards: LeaderboardSummary[]; + now: string; +} + +export interface UserSubmissionsResponse { + items: Array<{ + submission_id: number; + file_name?: string | null; + submitted_at: string; + status?: string | null; + submission_done: boolean; + runs?: Array<{ + start_time: string; + end_time: string | null; + mode: string; + passed: boolean; + score: number | null; + meta: Record | null; + report: Record | null; + }>; + }>; + total: number; + limit: number; +} + +export async function fetchLeaderBoard(id: string): Promise { const start = performance.now(); const res = await fetch(`/api/leaderboard/${id}`); const fetchTime = performance.now() - start; @@ -41,7 +111,7 @@ export async function fetchLeaderBoard(id: string): Promise { export async function fetchCodes( leaderboardId: number | string, submissionIds: (number | string)[], -): Promise { +): Promise { const res = await fetch("/api/codes", { method: "POST", headers: { @@ -62,7 +132,7 @@ export async function fetchCodes( return r.data; } -export async function fetchAllNews(): Promise { +export async function fetchAllNews(): Promise { const res = await fetch("/api/news"); if (!res.ok) { const json = await res.json(); @@ -73,7 +143,7 @@ export async function fetchAllNews(): Promise { return r.data; } -export async function fetchLeaderboardSummaries(useV1: boolean = false): Promise { +export async function fetchLeaderboardSummaries(useV1: boolean = false): Promise { const start = performance.now(); const url = useV1 ? "/api/leaderboard-summaries?v1_query" @@ -102,7 +172,7 @@ export async function fetchLeaderboardSummaries(useV1: boolean = false): Promise return r.data; } -export async function getMe(): Promise { +export async function getMe(): Promise { const res = await fetch("/api/me"); if (!res.ok) { const json = await res.json(); @@ -113,15 +183,14 @@ export async function getMe(): Promise { return r.data; } -export async function logout(): Promise { +export async function logout(): Promise { const res = await fetch("/api/logout"); if (!res.ok) { const json = await res.json(); const message = json?.message || "Unknown error"; throw new APIError(`Failed to fetch news contents: ${message}`, res.status); } - const r = await res.json(); - return r.data; + await res.json(); } export async function submitFile(form: FormData) { @@ -131,7 +200,7 @@ export async function submitFile(form: FormData) { }); const text = await resp.text(); - let data: any; + let data: Record; try { data = JSON.parse(text); } catch { @@ -139,7 +208,7 @@ export async function submitFile(form: FormData) { } if (!resp.ok) { - const msg = data?.detail || data?.message || "Submission failed"; + const msg = (data?.detail as string) || (data?.message as string) || "Submission failed"; throw new Error(msg); } @@ -151,7 +220,7 @@ export async function fetchUserSubmissions( userId: number | string, page: number = 1, pageSize: number = 10, -): Promise { +): Promise { const offset = (page - 1) * pageSize; const res = await fetch( `/api/submissions?leaderboard_id=${leaderboardId}&offset=${offset}&limit=${pageSize}`, @@ -190,7 +259,7 @@ export async function fetchEvents(): Promise { return r.data; } -export interface AiTrendDataPoint { +export interface CustomTrendDataPoint { score: string; submission_id: number; submission_time: string; @@ -200,23 +269,23 @@ export interface AiTrendDataPoint { model?: string; } -export interface AiTrendTimeSeries { +export interface CustomTrendTimeSeries { [gpuType: string]: { - [model: string]: AiTrendDataPoint[]; + [model: string]: CustomTrendDataPoint[]; }; } -export interface AiTrendResponse { +export interface CustomTrendResponse { leaderboard_id: number; - time_series: AiTrendTimeSeries; + time_series: CustomTrendTimeSeries; } -export async function fetchAiTrend(leaderboardId: string): Promise { - const res = await fetch(`/api/leaderboard/${leaderboardId}/ai_trend`); +export async function fetchCustomTrend(leaderboardId: string): Promise { + const res = await fetch(`/api/leaderboard/${leaderboardId}/custom_trend`); if (!res.ok) { const json = await res.json(); const message = json?.message || "Unknown error"; - throw new APIError(`Failed to fetch AI trend: ${message}`, res.status); + throw new APIError(`Failed to fetch custom trend: ${message}`, res.status); } const r = await res.json(); return r.data; @@ -225,7 +294,7 @@ export async function fetchAiTrend(leaderboardId: string): Promise = ({ remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeRaw, rehypeKatex]} components={{ - a: ({ node, ...props }) => ( + a: ({ node: _node, ...props }) => ( = ({ {...props} /> ), - figure: ({ node, ...props }) => ( + figure: ({ node: _node, ...props }) => (
), - figcaption: ({ node, ...props }) => ( + figcaption: ({ node: _node, ...props }) => (
= ({ {...props} /> ), - img: ({ node, ...props }) => { + img: ({ node: _node, ...props }) => { return (
{props.alt} diff --git a/frontend/src/lib/hooks/useApi.ts b/frontend/src/lib/hooks/useApi.ts index 6a767eaf..d34707f8 100644 --- a/frontend/src/lib/hooks/useApi.ts +++ b/frontend/src/lib/hooks/useApi.ts @@ -1,7 +1,7 @@ import { useCallback, useState } from "react"; import { useNavigate } from "react-router-dom"; -type Fetcher = (...args: Args) => Promise; +type Fetcher = (...args: Args) => Promise; export const defaultRedirectMap: Record = { 401: "/401", @@ -49,17 +49,17 @@ export const defaultRedirectMap: Record = { * ``` * @returns An object with `data`, `loading`, `error`, `errorStatus`, and `call` */ -export function fetcherApiCallback( +export function fetcherApiCallback( fetcher: Fetcher, redirectMap: Record = defaultRedirectMap, ) { - const navigate = useNavigate(); - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [errorStatus, setErrorStatus] = useState(null); - const [loading, setLoading] = useState(true); + const navigate = useNavigate(); // eslint-disable-line react-hooks/rules-of-hooks + const [data, setData] = useState(null); // eslint-disable-line react-hooks/rules-of-hooks + const [error, setError] = useState(null); // eslint-disable-line react-hooks/rules-of-hooks + const [errorStatus, setErrorStatus] = useState(null); // eslint-disable-line react-hooks/rules-of-hooks + const [loading, setLoading] = useState(true); // eslint-disable-line react-hooks/rules-of-hooks - const call = useCallback( + const call = useCallback( // eslint-disable-line react-hooks/rules-of-hooks async (...params: Args) => { setLoading(true); @@ -71,9 +71,10 @@ export function fetcherApiCallback( const result = await fetcher(...params); setData(result); return result; - } catch (e: any) { - let status = e.status ? e.status : 0; - let msg = e.message ? e.message : ""; + } catch (e: unknown) { + const err = e as { status?: number; message?: string }; + const status = err.status ? err.status : 0; + const msg = err.message ? err.message : ""; // set and logging the error if any setError(status); @@ -84,7 +85,7 @@ export function fetcherApiCallback( // navigate to config page if the status is found // otherwise passes - const redirectPath = redirectMap[e.status]; + const redirectPath = redirectMap[err.status ?? 0]; if (redirectPath) { navigate(redirectPath); } diff --git a/frontend/src/lib/store/authStore.ts b/frontend/src/lib/store/authStore.ts index ac636aca..be5886b8 100644 --- a/frontend/src/lib/store/authStore.ts +++ b/frontend/src/lib/store/authStore.ts @@ -9,7 +9,7 @@ type AuthState = { inFlight: boolean; setMe: (me: User | null) => void; fetchMe: () => Promise; - logoutAndRefresh: () => Promise; + logoutAndRefresh: () => Promise<{ ok: boolean; error?: unknown }>; }; export const useAuthStore = create((set, get) => ({ @@ -27,15 +27,15 @@ export const useAuthStore = create((set, get) => ({ ); try { const res = await getMe(); - set((s) => ({ + set((_s) => ({ me: res, loading: false, inFlight: false, error: null, })); - } catch (e: any) { - set((s) => ({ - error: e?.message ?? "Failed to fetch user", + } catch (e: unknown) { + set((_s) => ({ + error: e instanceof Error ? e.message : "Failed to fetch user", loading: false, inFlight: false, me: null, @@ -48,7 +48,7 @@ export const useAuthStore = create((set, get) => ({ await logout(); await get().fetchMe(); return { ok: true }; - } catch (e: any) { + } catch (e: unknown) { return { ok: false, error: e }; } }, diff --git a/frontend/src/pages/home/Home.test.tsx b/frontend/src/pages/home/Home.test.tsx index 38f8f2f2..0b0a44e0 100644 --- a/frontend/src/pages/home/Home.test.tsx +++ b/frontend/src/pages/home/Home.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, within } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { BrowserRouter } from "react-router-dom"; import { ThemeProvider } from "@mui/material"; import { appTheme } from "../../components/common/styles/theme"; diff --git a/frontend/src/pages/home/components/LeaderboardTile.tsx b/frontend/src/pages/home/components/LeaderboardTile.tsx index a6a23253..f365f015 100644 --- a/frontend/src/pages/home/components/LeaderboardTile.tsx +++ b/frontend/src/pages/home/components/LeaderboardTile.tsx @@ -1,4 +1,4 @@ -import { Box, Card, CardContent, Chip, Typography } from "@mui/material"; +import { Box, Card, CardContent, Chip, type Theme, Typography } from "@mui/material"; import { Link } from "react-router-dom"; import { getMedalIcon } from "../../../components/common/medal.tsx"; import { getTimeLeft } from "../../../lib/date/utils.ts"; @@ -17,7 +17,7 @@ const styles = { "&:hover": { transform: "translateY(-2px)", boxShadow: "0 4px 12px rgba(0,0,0,0.15)", - borderColor: (theme: any) => theme.palette.custom.secondary, + borderColor: (theme: Theme) => theme.palette.custom.secondary, }, padding: 1, "& .MuiCardContent-root:last-child": { diff --git a/frontend/src/pages/leaderboard/Leaderboard.test.tsx b/frontend/src/pages/leaderboard/Leaderboard.test.tsx index cdfdb19a..ff6e1f51 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.test.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, within } from "@testing-library/react"; +import { screen, fireEvent, within } from "@testing-library/react"; import { vi, expect, it, describe, beforeEach } from "vitest"; import Leaderboard from "./Leaderboard"; import * as apiHook from "../../lib/hooks/useApi"; @@ -18,7 +18,7 @@ let currentAuth: AuthState = { me: null }; vi.mock("../../lib/store/authStore", () => { return { // Simulate Zustand's selector pattern - useAuthStore: (selector: any) => + useAuthStore: (selector: (state: { me: AuthState["me"] }) => unknown) => selector({ me: currentAuth.me, }), diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index 5b2b7367..8ac793d3 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -9,7 +9,7 @@ import { Typography, } from "@mui/material"; import Grid from "@mui/material/Grid"; -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState } from "react"; import { fetchLeaderBoard, searchUsers } from "../../api/api"; import { fetcherApiCallback } from "../../lib/hooks/useApi"; import { isExpired, toDateUtc } from "../../lib/date/utils"; @@ -24,14 +24,13 @@ 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 AiTrendChart from "./components/AiTrendChart"; import UserTrendChart from "./components/UserTrendChart"; export const CardTitle = styled(Typography)(() => ({ fontSize: "1.5rem", fontWeight: "bold", })); -type TabKey = "rankings" | "reference" | "submission" | "ai_trend"; +type TabKey = "rankings" | "reference" | "submission"; // Tab accessibility props function a11yProps(index: number) { @@ -70,27 +69,16 @@ export default function Leaderboard() { const isAuthed = !!(me && me.authenticated); const userId = me?.user?.identity ?? null; - // State for top user (strongest submission) and default GPU type - const [defaultUser, setDefaultUser] = useState<{ - userId: string; - username: string; - } | null>(null); + // State for top users (strongest submissions) and default GPU type + const [defaultUsers, setDefaultUsers] = useState< + Array<{ userId: string; username: string }> + >([]); const [defaultGpuType, setDefaultGpuType] = useState(null); // Sync tab state with query parameter const [searchParams, setSearchParams] = useSearchParams(); - // Check if AI Trend should be shown - const showAiTrend = searchParams.get("showAiTrend") === "true"; - - // Build tab keys dynamically based on showAiTrend - const TAB_KEYS: TabKey[] = useMemo(() => { - const keys: TabKey[] = ["rankings", "reference", "submission"]; - if (showAiTrend) { - keys.push("ai_trend"); - } - return keys; - }, [showAiTrend]); + const TAB_KEYS: TabKey[] = ["rankings", "reference", "submission"]; const initialTabFromUrl = ((): TabKey => { const t = (searchParams.get("tab") || "").toLowerCase(); @@ -109,17 +97,17 @@ export default function Leaderboard() { next.set("tab", tab); setSearchParams(next, { replace: true }); } - }, [tab]); + }, [tab, searchParams, setSearchParams]); // Fetch leaderboard data useEffect(() => { if (id) call(id); - }, [id]); + }, [id, call]); - // Fetch top user (strongest submission) when rankings are available + // Fetch top users (strongest submissions) when rankings are available // Select from the GPU with the most unique users useEffect(() => { - const findTopUser = async () => { + const findTopUsers = async () => { if (!id || !data?.rankings) return; const gpuTypes = Object.keys(data.rankings); @@ -139,26 +127,35 @@ export default function Leaderboard() { const mostActiveGpuRankings = data.rankings[mostActiveGpu]; if (!mostActiveGpuRankings || mostActiveGpuRankings.length === 0) return; - // The first item is the top user (sorted by score ascending) - const topUserName = mostActiveGpuRankings[0].user_name; - if (!topUserName) return; + // Get top 5 users (sorted by score ascending) + const topUserNames = mostActiveGpuRankings + .slice(0, 5) + .map((r: any) => r.user_name) + .filter(Boolean); + + if (topUserNames.length === 0) return; try { - // Search for the user by username to get their user_id - const result = await searchUsers(id, topUserName, 1); - if (result.users && result.users.length > 0) { - const foundUser = result.users[0]; - setDefaultUser({ - userId: foundUser.user_id, - username: foundUser.username, - }); - } + // Search for each user by username to get their user_id + const userPromises = topUserNames.map((userName: string) => + searchUsers(id, userName, 1) + ); + const results = await Promise.all(userPromises); + + const foundUsers = results + .filter((result) => result.users && result.users.length > 0) + .map((result) => ({ + userId: result.users[0].user_id, + username: result.users[0].username, + })); + + setDefaultUsers(foundUsers); } catch (err) { - console.error("Failed to fetch top user:", err); + console.error("Failed to fetch top users:", err); } }; - findTopUser(); + findTopUsers(); }, [id, data?.rankings]); if (loading) return ; @@ -213,9 +210,6 @@ export default function Leaderboard() { - {showAiTrend && ( - - )} @@ -223,11 +217,20 @@ export default function Leaderboard() { {Object.entries(data.rankings).length > 0 ? ( - + <> + + + + + Performance Trend + + + + ) : ( @@ -269,7 +272,7 @@ export default function Leaderboard() { > Submission @@ -305,25 +308,6 @@ export default function Leaderboard() { )} - {/* AI Trend Tab - only shown when showAiTrend=true */} - {showAiTrend && ( - - - - - AI Model Performance Trend - - - - - - - User Performance Trend - - - - - )} ); diff --git a/frontend/src/pages/leaderboard/components/AiTrendChart.tsx b/frontend/src/pages/leaderboard/components/AiTrendChart.tsx deleted file mode 100644 index 8406da12..00000000 --- a/frontend/src/pages/leaderboard/components/AiTrendChart.tsx +++ /dev/null @@ -1,347 +0,0 @@ -import { useEffect, useState } from "react"; -import ReactECharts from "echarts-for-react"; -import { - Box, - Typography, - CircularProgress, - FormControl, - InputLabel, - Select, - MenuItem, -} from "@mui/material"; -import { fetchAiTrend, type AiTrendResponse } from "../../../api/api"; -import { - formatMicrosecondsNum, - formatMicroseconds, -} from "../../../lib/utils/ranking"; -import { useThemeStore } from "../../../lib/store/themeStore"; - -interface AiTrendChartProps { - leaderboardId: string; - rankings?: Record>; -} - -// Generate a consistent color from a string using hash -function hashStringToColor(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - hash = hash & hash; - } - - const hue = Math.abs(hash) % 360; - const saturation = 65 + (Math.abs(hash >> 8) % 20); - const lightness = 45 + (Math.abs(hash >> 16) % 15); - - return `hsl(${hue}, ${saturation}%, ${lightness}%)`; -} - -export default function AiTrendChart({ leaderboardId, rankings }: AiTrendChartProps) { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [selectedGpuType, setSelectedGpuType] = useState(""); - const resolvedMode = useThemeStore((state) => state.resolvedMode); - const isDark = resolvedMode === "dark"; - const textColor = isDark ? "#e0e0e0" : "#333"; - - const gpuTypes = data?.time_series ? Object.keys(data.time_series) : []; - - useEffect(() => { - if (gpuTypes.length > 0 && !selectedGpuType && data?.time_series) { - // Find the GPU type with the most unique users (from overall rankings) - // that also has actual AI data entries - let maxUniqueUsers = 0; - let defaultGpuType = ""; - - for (const gpuType of gpuTypes) { - const gpuData = data.time_series[gpuType]; - // Check if this GPU type has actual AI data entries - if (!gpuData || Object.keys(gpuData).length === 0) { - continue; - } - - let hasEntries = false; - for (const model of Object.keys(gpuData)) { - if (gpuData[model].length > 0) { - hasEntries = true; - break; - } - } - - if (!hasEntries) { - continue; - } - - // Count unique users from overall rankings (not just AI submissions) - const rankingsForGpu = rankings?.[gpuType]; - const uniqueUserCount = rankingsForGpu?.length ?? 0; - - if (uniqueUserCount > maxUniqueUsers) { - maxUniqueUsers = uniqueUserCount; - defaultGpuType = gpuType; - } - } - - // Fallback to first GPU type with AI data if no rankings match - if (!defaultGpuType) { - for (const gpuType of gpuTypes) { - const gpuData = data.time_series[gpuType]; - if (gpuData && Object.keys(gpuData).length > 0) { - for (const model of Object.keys(gpuData)) { - if (gpuData[model].length > 0) { - defaultGpuType = gpuType; - break; - } - } - if (defaultGpuType) break; - } - } - } - - // Final fallback to first GPU type - if (!defaultGpuType && gpuTypes.length > 0) { - defaultGpuType = gpuTypes[0]; - } - - setSelectedGpuType(defaultGpuType); - } - }, [gpuTypes, selectedGpuType, data?.time_series, rankings]); - - useEffect(() => { - const loadData = async () => { - setLoading(true); - setError(null); - try { - const result = await fetchAiTrend(leaderboardId); - setData(result); - } catch (err: any) { - setError(err.message || "Failed to load data"); - } finally { - setLoading(false); - } - }; - loadData(); - }, [leaderboardId]); - - if (loading) { - return ( - - - - ); - } - - if (error) { - return ( - - {error} - - ); - } - - if ( - !data || - !data.time_series || - Object.keys(data.time_series).length === 0 - ) { - return ( - - No AI data available - - ); - } - - const selectedData = selectedGpuType ? data.time_series[selectedGpuType] : null; - if (!selectedData || Object.keys(selectedData).length === 0) { - return ( - - - - GPU Type - - - - - - No {selectedGpuType || "AI"} data available - - - - ); - } - - // Build series for ECharts - const series: any[] = []; - - Object.entries(selectedData).forEach(([model, dataPoints]) => { - const color = hashStringToColor(model); - - const sortedData = [...dataPoints].sort( - (a, b) => - new Date(a.submission_time).getTime() - - new Date(b.submission_time).getTime() - ); - - series.push({ - name: model, - type: "line", - data: sortedData.map((point) => ({ - value: [ - new Date(point.submission_time).getTime(), - parseFloat(point.score), - ], - gpu_type: point.gpu_type, - })), - smooth: true, - symbol: "circle", - symbolSize: 8, - lineStyle: { - width: 2, - color, - }, - itemStyle: { - color, - }, - }); - }); - - const option = { - title: { - text: `AI Model Performance Trend (${selectedGpuType})`, - left: "center", - textStyle: { - fontSize: 16, - fontWeight: "bold", - color: textColor, - }, - }, - tooltip: { - trigger: "item", - formatter: (params: any) => { - const date = new Date(params.value[0]); - const score = formatMicroseconds(params.value[1]); - const gpuType = params.data.gpu_type || "Unknown"; - return ` - ${params.seriesName}
- GPU Type: ${gpuType}
- Time: ${date.toLocaleString()}
- Score: ${score} - `; - }, - }, - legend: { - data: Object.keys(selectedData), - bottom: 0, - textStyle: { - color: textColor, - }, - }, - grid: { - left: "3%", - right: "4%", - bottom: "15%", - top: "15%", - containLabel: true, - }, - xAxis: { - type: "time", - name: "Submission Time", - nameLocation: "middle", - nameGap: 30, - nameTextStyle: { - color: textColor, - }, - axisLabel: { - color: textColor, - formatter: (value: number) => { - const date = new Date(value); - return `${date.getMonth() + 1}/${date.getDate()}`; - }, - }, - axisLine: { - lineStyle: { - color: textColor, - }, - }, - }, - yAxis: { - type: "value", - name: "Score (lower is better)", - nameLocation: "middle", - nameGap: 70, - nameTextStyle: { - color: textColor, - }, - axisLabel: { - color: textColor, - formatter: (value: number) => `${formatMicrosecondsNum(value)}μs`, - }, - axisLine: { - lineStyle: { - color: textColor, - }, - }, - splitLine: { - lineStyle: { - color: isDark ? "#444" : "#ccc", - }, - }, - }, - series, - }; - - return ( - - - - GPU Type - - - - - - ); -} diff --git a/frontend/src/pages/leaderboard/components/CodeDialog.tsx b/frontend/src/pages/leaderboard/components/CodeDialog.tsx index df04e714..17f28977 100644 --- a/frontend/src/pages/leaderboard/components/CodeDialog.tsx +++ b/frontend/src/pages/leaderboard/components/CodeDialog.tsx @@ -18,7 +18,7 @@ export function CodeDialog({ userName, problemName, }: { - code: any; + code: string | null | undefined; fileName?: string; isActive?: boolean; rank?: number; diff --git a/frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx index 28a7dc07..7f8c2235 100644 --- a/frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx +++ b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.test.tsx @@ -17,7 +17,7 @@ vi.mock("../../../api/api", () => ({ // Make AlertBar deterministic and closable vi.mock("../../../components/alert/AlertBar", () => ({ __esModule: true, - default: ({ notice, onClose }: any) => + default: ({ notice, onClose }: { notice: { open: boolean; title?: string; message?: string }; onClose: () => void }) => notice?.open ? (
{notice.title}
@@ -79,7 +79,7 @@ function getHiddenFileInput(): HTMLInputElement { } async function formDataToObject(fd: FormData) { - const out: Record = {}; + const out: Record = {}; fd.forEach((v, k) => (out[k] = v)); return out; } diff --git a/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx index 26a222e1..4c1b6816 100644 --- a/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx +++ b/frontend/src/pages/leaderboard/components/LeaderboardSubmit.tsx @@ -126,8 +126,8 @@ export default function LeaderboardSubmit({ setOpen(false); resetForm(); }, 100); - } catch (e: any) { - setStatus({ kind: "error", msg: e?.message || "Submission failed" }); + } catch (e: unknown) { + setStatus({ kind: "error", msg: e instanceof Error ? e.message : "Submission failed" }); } } diff --git a/frontend/src/pages/leaderboard/components/RankingLists.tsx b/frontend/src/pages/leaderboard/components/RankingLists.tsx index e9fe3b35..0a286d6a 100644 --- a/frontend/src/pages/leaderboard/components/RankingLists.tsx +++ b/frontend/src/pages/leaderboard/components/RankingLists.tsx @@ -85,7 +85,7 @@ export default function RankingsList({ const me = useAuthStore((s) => s.me); const isAdmin = !!me?.user?.is_admin; const [expanded, setExpanded] = useState>({}); - const [colorHash, _] = useState( + const [colorHash] = useState( Math.random().toString(36).slice(2, 8), ); const [codes, setCodes] = useState>(new Map()); @@ -93,8 +93,8 @@ export default function RankingsList({ const submissionIds = useMemo(() => { if (!rankings) return []; const ids: number[] = []; - Object.entries(rankings).forEach(([key, value]) => { - const li = value as any[]; + Object.entries(rankings).forEach(([_key, value]) => { + const li = value as RankingItem[]; if (Array.isArray(li) && li.length > 0) { li.forEach((item) => { if (item?.submission_id) { diff --git a/frontend/src/pages/leaderboard/components/UserTrendChart.tsx b/frontend/src/pages/leaderboard/components/UserTrendChart.tsx index e552feb1..9240bebf 100644 --- a/frontend/src/pages/leaderboard/components/UserTrendChart.tsx +++ b/frontend/src/pages/leaderboard/components/UserTrendChart.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useCallback } from "react"; import ReactECharts from "echarts-for-react"; import { Box, @@ -11,23 +11,43 @@ import { InputLabel, Select, MenuItem, + Button, } from "@mui/material"; import { fetchUserTrend, + fetchCustomTrend, searchUsers, type UserTrendResponse, + type CustomTrendResponse, type UserSearchResult, } from "../../../api/api"; + +// Display name prefix for custom (KernelAgent) entries +const CUSTOM_ENTRY_PREFIX = "KernelAgent"; + +// Simple option type - custom entries are identified by id starting with "custom_" to avoid collisions +interface TrendOption { + id: string; + label: string; +} import { formatMicrosecondsNum, formatMicroseconds, } from "../../../lib/utils/ranking"; import { useThemeStore } from "../../../lib/store/themeStore"; +interface RankingEntry { + user_name: string; + score: number; + file_name?: string; + submission_id?: number; +} + interface UserTrendChartProps { leaderboardId: string; - defaultUser?: { userId: string; username: string } | null; + defaultUsers?: Array<{ userId: string; username: string }>; defaultGpuType?: string | null; + rankings?: Record; } function hashStringToColor(str: string): string { @@ -44,20 +64,59 @@ function hashStringToColor(str: string): string { return `hsl(${hue}, ${saturation}%, ${lightness}%)`; } -export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuType }: UserTrendChartProps) { +export default function UserTrendChart({ leaderboardId, defaultUsers, defaultGpuType, rankings }: UserTrendChartProps) { const [data, setData] = useState(null); + const [customData, setCustomData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedGpuType, setSelectedGpuType] = useState(defaultGpuType || ""); + const [resetting, setResetting] = useState(false); const resolvedMode = useThemeStore((state) => state.resolvedMode); const isDark = resolvedMode === "dark"; const textColor = isDark ? "#e0e0e0" : "#333"; - const [selectedUsers, setSelectedUsers] = useState([]); + const [selectedOptions, setSelectedOptions] = useState(() => { + if (defaultUsers && defaultUsers.length > 0) { + return defaultUsers.map((user) => ({ + id: user.userId, + label: user.username, + })); + } + return []; + }); const [userOptions, setUserOptions] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [inputValue, setInputValue] = useState(""); + // Fetch custom trend data on mount + useEffect(() => { + const loadCustomData = async () => { + try { + const result = await fetchCustomTrend(leaderboardId); + setCustomData(result); + } catch (err) { + console.error("Failed to load custom trend data:", err); + } + }; + loadCustomData(); + }, [leaderboardId]); + + // Build combined options: users + custom entries + // Custom entries are identified by id starting with "custom_" + // Sort all options alphabetically by label + const combinedOptions: TrendOption[] = [ + ...userOptions.map((u) => ({ + id: u.user_id, + label: u.username, + })), + ...(customData?.time_series?.[selectedGpuType] + ? Object.keys(customData.time_series[selectedGpuType]).map((model) => ({ + id: `custom_${model}`, + label: `${CUSTOM_ENTRY_PREFIX} - ${model}`, + })) + : []), + ].sort((a, b) => a.label.localeCompare(b.label)); + const loadData = useCallback( async (userIds: string[]) => { if (userIds.length === 0) { @@ -86,8 +145,8 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT } setSelectedGpuType(defaultGpu); } - } catch (err: any) { - setError(err.message || "Failed to load data"); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to load data"); } finally { setLoading(false); } @@ -111,18 +170,30 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT loadInitialUsers(); }, [leaderboardId]); - // Pre-select the default user if provided + // Load data for default users when they arrive (only when defaultUsers changes) useEffect(() => { - if (!defaultUser?.userId) return; - - const defaultUserAsSearchResult: UserSearchResult = { - user_id: defaultUser.userId, - username: defaultUser.username, - }; + if (defaultUsers && defaultUsers.length > 0) { + // Update selected options when defaults arrive + setSelectedOptions(defaultUsers.map((user) => ({ + id: user.userId, + label: user.username, + }))); + // Fetch data for the default users + const userIds = defaultUsers.map((u) => u.userId); + fetchUserTrend(leaderboardId, userIds).then((result) => { + setData(result); + }).catch((err) => { + console.error("Failed to load default users data:", err); + }); + } + }, [defaultUsers, leaderboardId]); - setSelectedUsers([defaultUserAsSearchResult]); - loadData([defaultUser.userId]); - }, [defaultUser, loadData]); + // Update GPU type when defaultGpuType changes + useEffect(() => { + if (defaultGpuType) { + setSelectedGpuType(defaultGpuType); + } + }, [defaultGpuType]); // Search users when input changes useEffect(() => { @@ -141,37 +212,88 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT return () => clearTimeout(searchTimeout); }, [inputValue, leaderboardId]); - const handleUserSelectionChange = ( - _event: any, - newValue: UserSearchResult[] + // Helper to check if option is a custom entry (id starts with "custom_") + const isCustomEntry = (opt: TrendOption) => opt.id.startsWith("custom_"); + + const handleOptionSelectionChange = ( + _event: React.SyntheticEvent, + newValue: TrendOption[] ) => { - setSelectedUsers(newValue); - const userIds = newValue.map((u) => u.user_id); + setSelectedOptions(newValue); + const userIds = newValue + .filter((opt) => !isCustomEntry(opt)) + .map((opt) => opt.id); loadData(userIds); }; - const gpuTypes = data?.time_series ? Object.keys(data.time_series) : []; + // Reset to top 5 users for current GPU type + const handleReset = async () => { + if (!rankings || !selectedGpuType) return; + + const gpuRankings = rankings[selectedGpuType]; + if (!gpuRankings || gpuRankings.length === 0) return; + + setResetting(true); + try { + // Get top 5 users for this GPU type + const topUserNames = gpuRankings + .slice(0, 5) + .map((r) => r.user_name) + .filter(Boolean); + + if (topUserNames.length === 0) return; + + // Search for each user by username to get their user_id + const userPromises = topUserNames.map((userName: string) => + searchUsers(leaderboardId, userName, 1) + ); + const results = await Promise.all(userPromises); + + const foundUsers = results + .filter((result) => result.users && result.users.length > 0) + .map((result) => ({ + id: result.users[0].user_id, + label: result.users[0].username, + })); + + setSelectedOptions(foundUsers); + const userIds = foundUsers.map((u) => u.id); + loadData(userIds); + } catch (err) { + console.error("Failed to reset to top users:", err); + } finally { + setResetting(false); + } + }; + + // Get selected users and custom entries separately + const selectedUsers = selectedOptions.filter((opt) => !isCustomEntry(opt)); + const selectedCustomEntries = selectedOptions.filter((opt) => isCustomEntry(opt)); + + // GPU types from user data or custom data + const gpuTypesFromUsers = data?.time_series ? Object.keys(data.time_series) : []; + const gpuTypesFromCustom = customData?.time_series ? Object.keys(customData.time_series) : []; + const gpuTypes = [...new Set([...gpuTypesFromUsers, ...gpuTypesFromCustom])]; const renderSearchInput = () => ( setInputValue(newInputValue)} - getOptionLabel={(option) => option.username} - isOptionEqualToValue={(option, value) => - option.user_id === value.user_id - } + getOptionLabel={(option) => option.label} + isOptionEqualToValue={(option, value) => option.id === value.id} loading={searchLoading} renderInput={(params) => ( @@ -205,11 +327,11 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT const { key, ...restProps } = props; return (
  • - {option.username} + {option.label}
  • ); }} - noOptionsText="No users found" + noOptionsText="No contestants found" slotProps={{ listbox: { style: { maxHeight: 300 } }, }} @@ -231,10 +353,21 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT )} + {rankings && selectedGpuType && ( + + )}
    ); - if (selectedUsers.length === 0) { + if (selectedUsers.length === 0 && selectedCustomEntries.length === 0) { return ( {renderSearchInput()} @@ -284,11 +417,11 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT ); } - if ( - !data || - !data.time_series || - Object.keys(data.time_series).length === 0 - ) { + // When only custom entries are selected, we don't need user data + const hasUserData = data?.time_series && Object.keys(data.time_series).length > 0; + const hasCustomSelection = selectedCustomEntries.length > 0; + + if (!hasUserData && !hasCustomSelection) { return ( {renderSearchInput()} @@ -306,8 +439,11 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT ); } - const gpuData = data.time_series[selectedGpuType]; - if (!gpuData || Object.keys(gpuData).length === 0) { + // Use user data GPU type or fall back to first available AI GPU type + const effectiveGpuType = selectedGpuType || gpuTypes[0] || ""; + const gpuData = data?.time_series?.[effectiveGpuType] || {}; + + if (Object.keys(gpuData).length === 0 && !hasCustomSelection) { return ( {renderSearchInput()} @@ -318,14 +454,14 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT minHeight={400} > - No {selectedGpuType} data available for selected users + No {effectiveGpuType} data available for selected users ); } - const series: any[] = []; + const series: Array> = []; Object.entries(gpuData).forEach(([userId, dataPoints]) => { const sortedData = [...dataPoints].sort( @@ -361,7 +497,50 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT }); }); - const chartTitle = `User Performance Trend (${selectedGpuType})`; + // Add custom entry series if selected + if (selectedCustomEntries.length > 0 && customData?.time_series?.[effectiveGpuType]) { + const customGpuData = customData.time_series[effectiveGpuType]; + + selectedCustomEntries.forEach((opt) => { + const model = opt.id.replace("custom_", ""); + const customDataPoints = customGpuData[model]; + if (!customDataPoints || customDataPoints.length === 0) return; + + const sortedCustomData = [...customDataPoints].sort( + (a, b) => + new Date(a.submission_time).getTime() - + new Date(b.submission_time).getTime() + ); + + const displayName = `${CUSTOM_ENTRY_PREFIX} - ${model}`; + const color = hashStringToColor(`custom_${model}`); + + series.push({ + name: displayName, + type: "line", + data: sortedCustomData.map((point) => ({ + value: [ + new Date(point.submission_time).getTime(), + parseFloat(point.score), + ], + gpu_type: point.gpu_type, + user_name: displayName, + })), + smooth: true, + symbol: "circle", + symbolSize: 8, + lineStyle: { + width: 2, + color, + }, + itemStyle: { + color, + }, + }); + }); + } + + const chartTitle = `Performance Trend (${selectedGpuType})`; const option = { title: { @@ -375,7 +554,7 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT }, tooltip: { trigger: "item", - formatter: (params: any) => { + formatter: (params: { value: [number, number]; data: { gpu_type?: string; user_name?: string }; seriesName: string }) => { const date = new Date(params.value[0]); const score = formatMicroseconds(params.value[1]); const gpuType = params.data.gpu_type || "Unknown"; @@ -396,7 +575,7 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT }, }, grid: { - left: "3%", + left: "5%", right: "4%", bottom: "15%", top: "15%", @@ -427,7 +606,7 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT type: "value", name: "Score (lower is better)", nameLocation: "middle", - nameGap: 70, + nameGap: 90, nameTextStyle: { color: textColor, }, @@ -452,7 +631,7 @@ export default function UserTrendChart({ leaderboardId, defaultUser, defaultGpuT return ( {renderSearchInput()} - + ); } diff --git a/frontend/src/pages/leaderboard/components/submission-history/ReportCell.tsx b/frontend/src/pages/leaderboard/components/submission-history/ReportCell.tsx index 269aab91..026fb7e5 100644 --- a/frontend/src/pages/leaderboard/components/submission-history/ReportCell.tsx +++ b/frontend/src/pages/leaderboard/components/submission-history/ReportCell.tsx @@ -8,7 +8,7 @@ import { TableCell, } from "@mui/material"; -export function ReportCell({ report }: { report: any }) { +export function ReportCell({ report }: { report: string | null | undefined }) { const [open, setOpen] = useState(false); return ( diff --git a/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx index da01ee70..7ac1cb3a 100644 --- a/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx +++ b/frontend/src/pages/leaderboard/components/submission-history/SubmissionHistorySection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Typography, @@ -90,7 +90,7 @@ const styles = { export default function SubmissionHistorySection({ leaderboardId, - leaderboardName, + leaderboardName: _leaderboardName, userId, pageSize = 10, refreshFlag, @@ -107,6 +107,12 @@ export default function SubmissionHistorySection({ const { data, loading, error, errorStatus, call } = fetcherApiCallback(fetchUserSubmissions); + const refresh = useCallback(() => { + if (!leaderboardId || !userId) return; + call(leaderboardId, userId, page, pageSize); + setLastRefresh(new Date()); + }, [leaderboardId, userId, page, pageSize, call]); + useEffect(() => { const id = setInterval(() => setNow(Date.now()), 30_000); return () => clearInterval(id); @@ -119,7 +125,7 @@ export default function SubmissionHistorySection({ useEffect(() => { refresh(); - }, [refreshFlag]); + }, [refreshFlag, refresh]); // fetch when inputs or page change useEffect(() => { @@ -128,10 +134,10 @@ export default function SubmissionHistorySection({ setLastRefresh(new Date()); }, [leaderboardId, userId, page, pageSize, call]); - let totalPages = + const totalPages = data?.limit && data?.total ? Math.ceil(data?.total / data?.limit) : 1; - let items: Submission[] = data?.items ?? []; - let total: number = data?.total ?? 0; + const items: Submission[] = useMemo(() => data?.items ?? [], [data?.items]); + const total: number = data?.total ?? 0; const tooOld = lastRefresh && now - lastRefresh.getTime() > 10 * 60 * 1000; @@ -152,12 +158,6 @@ export default function SubmissionHistorySection({ setOpenMap((m) => ({ ...m, [id]: !m[id] })); }; - const refresh = () => { - if (!leaderboardId || !userId) return; - call(leaderboardId, userId, page, pageSize); - setLastRefresh(new Date()); - }; - const stabelItems = useMemo( () => [...items].sort( diff --git a/frontend/src/pages/leaderboard/components/submission-history/SubmissionRunsTable.tsx b/frontend/src/pages/leaderboard/components/submission-history/SubmissionRunsTable.tsx index 2d0a9c14..c117605a 100644 --- a/frontend/src/pages/leaderboard/components/submission-history/SubmissionRunsTable.tsx +++ b/frontend/src/pages/leaderboard/components/submission-history/SubmissionRunsTable.tsx @@ -22,8 +22,8 @@ export type SubmissionRun = { mode: string; passed: boolean; score: number | null; - meta: any | null; // present, but null when passed = true - report: any | null; // present, but null when passed = true + meta: Record | null; + report: Record | null; }; // --- Child table for runs (rendered inside Collapse) --- @@ -44,16 +44,16 @@ export function SubmissionRunsTable({ runs }: { runs: SubmissionRun[] }) { {runs.map((r, idx) => { const debug_info = { - stderr: r.meta?.stderr, - success: r.meta?.success, - exit_code: r.meta?.exit_code, - exit_code_info: r.meta?.exit_code - ? getExitCodeMessage(r.meta?.exit_code) + stderr: (r.meta as Record)?.stderr, + success: (r.meta as Record)?.success, + exit_code: (r.meta as Record)?.exit_code, + exit_code_info: (r.meta as Record)?.exit_code + ? getExitCodeMessage((r.meta as Record)?.exit_code as number) : null, - duration: r.meta?.duration, + duration: (r.meta as Record)?.duration, }; - const report = r.report?.log; + const report = (r.report as Record)?.log as string | undefined; return ( diff --git a/frontend/src/pages/leaderboard/components/submission-history/SubmissionStatusChip.tsx b/frontend/src/pages/leaderboard/components/submission-history/SubmissionStatusChip.tsx index 17258bc1..2da80fa0 100644 --- a/frontend/src/pages/leaderboard/components/submission-history/SubmissionStatusChip.tsx +++ b/frontend/src/pages/leaderboard/components/submission-history/SubmissionStatusChip.tsx @@ -29,7 +29,7 @@ function SubmissionStatusChip({ status }: { status?: string | null }) { diff --git a/frontend/src/pages/login/login.test.tsx b/frontend/src/pages/login/login.test.tsx index ae52d296..c5a9b94b 100644 --- a/frontend/src/pages/login/login.test.tsx +++ b/frontend/src/pages/login/login.test.tsx @@ -4,7 +4,7 @@ import Login from "./login"; import { describe, expect, it, vi } from "vitest"; vi.mock("../../components/alert/AlertBar", () => ({ - default: ({ notice }: { notice: any }) => ( + default: ({ notice }: { notice: { open: boolean; title?: string; message?: string; status?: number | null } }) => (
    diff --git a/frontend/src/pages/news/News.test.tsx b/frontend/src/pages/news/News.test.tsx index b616338a..14f8f822 100644 --- a/frontend/src/pages/news/News.test.tsx +++ b/frontend/src/pages/news/News.test.tsx @@ -8,6 +8,7 @@ import { import { vi, describe, it, expect, beforeEach } from "vitest"; import News from "./News"; // 假设你当前文件路径为 pages/News.tsx import * as apiHook from "../../lib/hooks/useApi"; +import { useParams, useNavigate } from "react-router-dom"; // 统一 mock useApi hook vi.mock("../../lib/hooks/useApi", () => ({ @@ -52,7 +53,6 @@ const mockData = [ describe("News", () => { beforeEach(() => { vi.clearAllMocks(); - const { useParams, useNavigate } = require("react-router-dom"); (useParams as ReturnType).mockReturnValue({}); (useNavigate as ReturnType).mockReturnValue(mockNavigate); }); @@ -181,7 +181,6 @@ describe("News", () => { it("scrolls to section when slug is provided in URL", async () => { // prepare const scrollIntoViewMock = vi.fn(); - const { useParams } = require("react-router-dom"); (useParams as ReturnType).mockReturnValue({ slug: "news-2" }); const mockHookReturn = { diff --git a/frontend/src/pages/news/News.tsx b/frontend/src/pages/news/News.tsx index 1c562904..2da20c7d 100644 --- a/frontend/src/pages/news/News.tsx +++ b/frontend/src/pages/news/News.tsx @@ -1,3 +1,4 @@ +import type { NewsPost } from "../../api/api"; import { ErrorAlert } from "../../components/alert/ErrorAlert"; import { fetcherApiCallback } from "../../lib/hooks/useApi"; import { fetchAllNews } from "../../api/api"; @@ -14,14 +15,14 @@ export default function News() { useEffect(() => { call(); - }, []); + }, [call]); if (loading) return ; if (error) return ; // If slug is provided, find and show that specific post if (slug) { - const post = data?.find((item: any) => item.id === slug); + const post = data?.find((item: NewsPost) => item.id === slug); if (!post) { return ; } diff --git a/kernelboard/__init__.py b/kernelboard/__init__.py index 7fa3921d..e4158156 100644 --- a/kernelboard/__init__.py +++ b/kernelboard/__init__.py @@ -1,22 +1,26 @@ import http import os -from re import L + from dotenv import load_dotenv -from flask import Flask, jsonify, redirect, session, g -from flask_login import LoginManager, current_user +from flask import Flask, make_response, redirect, send_from_directory +from flask_login import LoginManager from flask_session import Session from flask_talisman import Talisman -from kernelboard.api.auth import User, providers -from kernelboard.lib import db, env, time, score -from kernelboard import color, error, health, index, leaderboard, news + +from kernelboard import color, health +from kernelboard import error as error +from kernelboard import index as index +from kernelboard import leaderboard as leaderboard +from kernelboard import news as news from kernelboard.api import create_api_blueprint -from kernelboard.lib.redis_connection import create_redis_connection -from flask import send_from_directory, make_response +from kernelboard.api.auth import User, providers +from kernelboard.lib import db, env, score, time from kernelboard.lib.logging import configure_logging -from kernelboard.og_tags import is_social_crawler, get_og_tags_for_path, inject_og_tags -from flask_limiter import Limiter from kernelboard.lib.rate_limiter import limiter +from kernelboard.lib.redis_connection import create_redis_connection from kernelboard.lib.status_code import http_error +from kernelboard.og_tags import get_og_tags_for_path, inject_og_tags, is_social_crawler + def create_app(test_config=None): # Check if we're in development mode: @@ -123,7 +127,7 @@ def redirect_v2(path=""): return redirect(f"/{path}", code=301) @app.errorhandler(401) - def unauthorized(_error): + def handle_401(_error): return redirect("/401") @app.errorhandler(404) diff --git a/kernelboard/api/__init__.py b/kernelboard/api/__init__.py index 5dcb736a..0acf139e 100644 --- a/kernelboard/api/__init__.py +++ b/kernelboard/api/__init__.py @@ -1,15 +1,13 @@ -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.auth import auth_bp +from kernelboard.api.events import events_bp 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 -from kernelboard.api.events import events_bp - +from kernelboard.lib.status_code import http_error, http_success def create_api_blueprint(): diff --git a/kernelboard/api/auth.py b/kernelboard/api/auth.py index 4a46893c..12e3c0d2 100644 --- a/kernelboard/api/auth.py +++ b/kernelboard/api/auth.py @@ -7,15 +7,17 @@ import requests from flask import ( Blueprint, - current_app as app, redirect, request, session, url_for, ) +from flask import ( + current_app as app, +) 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.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__) diff --git a/kernelboard/api/events.py b/kernelboard/api/events.py index 5a053acd..02f63a2a 100644 --- a/kernelboard/api/events.py +++ b/kernelboard/api/events.py @@ -1,11 +1,12 @@ -from http import HTTPStatus +import logging import os import time +from http import HTTPStatus + import requests from flask import Blueprint -from kernelboard.lib.status_code import http_error, http_success -import logging +from kernelboard.lib.status_code import http_error, http_success logger = logging.getLogger(__name__) diff --git a/kernelboard/api/leaderboard.py b/kernelboard/api/leaderboard.py index 80cf3c31..9aa34e1c 100644 --- a/kernelboard/api/leaderboard.py +++ b/kernelboard/api/leaderboard.py @@ -1,12 +1,13 @@ -import re +import logging +import time +from http import HTTPStatus from typing import Any, List + 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 http import HTTPStatus -import time -import logging +from kernelboard.lib.time import to_time_left logger = logging.getLogger(__name__) @@ -197,12 +198,12 @@ def is_result_invalid(result): HARDCODED_USER_ID = "205851652572315658" -@leaderboard_bp.route("//ai_trend", methods=["GET"]) -def get_ai_trend(leaderboard_id: int): +@leaderboard_bp.route("//custom_trend", methods=["GET"]) +def get_custom_trend(leaderboard_id: int): """ - GET /leaderboard//ai_trend + GET /leaderboard//custom_trend - Returns time series data for ai_trend matching file name patterns like: + Returns time series data for custom submissions matching file name patterns like: - H100_claude-opus-4.5_ka_submission - H100_gpt-5-2_ka_submission - H100_gpt-5_ka_submission @@ -269,7 +270,7 @@ def get_ai_trend(leaderboard_id: int): total_time = (time.perf_counter() - total_start) * 1000 logger.info( - "[Perf] timeseries leaderboard_id=%s | query=%.2fms | total=%.2fms", + "[Perf] custom_trend leaderboard_id=%s | query=%.2fms | total=%.2fms", leaderboard_id, query_time, total_time, ) @@ -466,7 +467,7 @@ def group_multi_user_submissions( ) -> dict: """ Group submissions from multiple users by gpu_type with username as series. - Same format as ai_trend: { "H100": { "user1": [...], "user2": [...] } } + Same format as custom_trend: { "H100": { "user1": [...], "user2": [...] } } """ series = {} for user_id, items in items_by_user.items(): diff --git a/kernelboard/api/leaderboard_summaries.py b/kernelboard/api/leaderboard_summaries.py index 4d0aef1f..65b3ad4b 100644 --- a/kernelboard/api/leaderboard_summaries.py +++ b/kernelboard/api/leaderboard_summaries.py @@ -1,9 +1,11 @@ -from flask import Blueprint, request +import logging +import time from datetime import datetime, timezone + +from flask import Blueprint, request + from kernelboard.lib.db import get_db_connection from kernelboard.lib.status_code import http_success -import time -import logging logger = logging.getLogger(__name__) diff --git a/kernelboard/api/news.py b/kernelboard/api/news.py index e65b9ce2..19367076 100644 --- a/kernelboard/api/news.py +++ b/kernelboard/api/news.py @@ -1,11 +1,12 @@ -from http import HTTPStatus +import logging import os +from datetime import datetime +from http import HTTPStatus + import yaml from flask import Blueprint, current_app -from kernelboard.lib.status_code import HttpError, http_error, http_success -from datetime import datetime -import logging +from kernelboard.lib.status_code import HttpError, http_error, http_success # logger for blueprint news_bp logger = logging.getLogger(__name__) diff --git a/kernelboard/api/submission.py b/kernelboard/api/submission.py index 8b758619..3e2520b5 100644 --- a/kernelboard/api/submission.py +++ b/kernelboard/api/submission.py @@ -1,8 +1,16 @@ +import base64 import http +import json +import logging +import os +import textwrap +import time from typing import Any, List, Optional, Tuple -from flask import Blueprint, request, jsonify -from flask_login import login_required, current_user + import requests +from flask import Blueprint, jsonify, request +from flask_login import current_user, login_required + from kernelboard.lib.auth_utils import ( get_id_and_username_from_session, get_whitelist, @@ -10,18 +18,8 @@ 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 -from typing import Any, List, Tuple -import json -from typing import Any, Tuple, List -import json -import base64 -import textwrap - +from kernelboard.lib.status_code import http_error, http_success logger = logging.getLogger(__name__) @@ -58,7 +56,10 @@ def submission(): if not web_token: logger.error("user %s missing web token", user_id) return http_error( - message="cannot find user info from db for user. if this is a bug, please contact the gpumode administrator", + 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() @@ -125,7 +126,7 @@ def submission(): @submission_bp.route("/codes", methods=["POST"]) -def list_codes(): +def list_codes_route(): """ POST /codes Body example: @@ -175,7 +176,8 @@ def list_codes(): data={"results": results}, ) else: - # otherwise, check if user able to see the leaderboard codes (only admin can see the leaderboard codes if leaderboard is not ended) + # otherwise, check if user able to see the leaderboard codes + # (only admin can see the leaderboard codes if leaderboard is not ended) return check_admin_access_codes(user_id, leaderboard_id, submission_ids) except Exception as e: logger.error(f"faild to list codes: {e}") @@ -472,38 +474,6 @@ def log_one(base_name): return "❗ Could not find any profiling data" -def make_benchmark_log(result: dict) -> str: - num_bench = int(result.get("benchmark-count", 0)) - - def log_one(base_name): - status = result.get(f"{base_name}.status") - spec = result.get(f"{base_name}.spec") - if status == "fail": - bench_log.append(f"❌ {spec} failed testing:\n") - bench_log.append(result.get(f"{base_name}.error")) - return - - mean = result.get(f"{base_name}.mean") - err = result.get(f"{base_name}.err") - best = result.get(f"{base_name}.best") - worst = result.get(f"{base_name}.worst") - - bench_log.append(f"{spec}") - bench_log.append(f" ⏱ {format_time(mean, err)}") - if best is not None and worst is not None: - bench_log.append(f" ⚡ {format_time(best)} 🐌 {format_time(worst)}") - - bench_log = [] - for i in range(num_bench): - log_one(f"benchmark.{i}") - bench_log.append("") - - if len(bench_log) > 0: - return "\n".join(bench_log) - else: - return "❗ Could not find any benchmarks" - - def _is_crash_report(compilation: dict, passed: bool): if not passed: return True diff --git a/kernelboard/error.py b/kernelboard/error.py index c0d2eb91..4c1c03eb 100644 --- a/kernelboard/error.py +++ b/kernelboard/error.py @@ -1,6 +1,5 @@ from flask import Blueprint, render_template - blueprint = Blueprint("error", __name__, url_prefix="/") diff --git a/kernelboard/health.py b/kernelboard/health.py index 27b06830..0a460f2c 100644 --- a/kernelboard/health.py +++ b/kernelboard/health.py @@ -1,12 +1,15 @@ import os -from flask import Blueprint, current_app as app +from http import HTTPStatus + +from flask import Blueprint +from flask import current_app as app + from kernelboard.lib.db import get_db_connection +from kernelboard.lib.redis_connection import create_redis_connection from kernelboard.lib.status_code import ( - http_success, http_error, + http_success, ) -from http import HTTPStatus -from kernelboard.lib.redis_connection import create_redis_connection blueprint = Blueprint("health", __name__, url_prefix="/health") @@ -25,7 +28,7 @@ def health(): cert_reqs = os.getenv("REDIS_SSL_CERT_REQS") redis_conn = create_redis_connection(cert_reqs=cert_reqs) - if redis_conn == None: + if redis_conn is None: app.logger.error("redis_conn is None. Is REDIS_URL set?") all_checks_passed = False else: diff --git a/kernelboard/index.py b/kernelboard/index.py index b7efa575..691fcfb4 100644 --- a/kernelboard/index.py +++ b/kernelboard/index.py @@ -1,5 +1,7 @@ -from flask import Blueprint, render_template from datetime import datetime, timezone + +from flask import Blueprint, render_template + from kernelboard.lib.db import get_db_connection blueprint = Blueprint("index", __name__, url_prefix="/") @@ -127,9 +129,9 @@ def index(): cur.execute(query) leaderboards = [row[0] for row in cur.fetchall()] - for l in leaderboards: - if l["gpu_types"] is None: - l["gpu_types"] = [] + for lb in leaderboards: + if lb["gpu_types"] is None: + lb["gpu_types"] = [] return render_template( "index.html", leaderboards=leaderboards, now=datetime.now(timezone.utc) diff --git a/kernelboard/leaderboard.py b/kernelboard/leaderboard.py index 6d7130ac..7f50506e 100644 --- a/kernelboard/leaderboard.py +++ b/kernelboard/leaderboard.py @@ -1,4 +1,5 @@ -from flask import abort, Blueprint, render_template +from flask import Blueprint, abort, render_template + from kernelboard.lib.db import get_db_connection from kernelboard.lib.time import to_time_left diff --git a/kernelboard/lib/auth_utils.py b/kernelboard/lib/auth_utils.py index c3f5e578..849ee59f 100644 --- a/kernelboard/lib/auth_utils.py +++ b/kernelboard/lib/auth_utils.py @@ -1,12 +1,11 @@ +import logging import secrets -import os 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__) diff --git a/kernelboard/lib/db.py b/kernelboard/lib/db.py index 8c741c3f..f0b4f7be 100644 --- a/kernelboard/lib/db.py +++ b/kernelboard/lib/db.py @@ -1,6 +1,8 @@ -import psycopg2 -from flask import g, Flask, current_app import logging + +import psycopg2 +from flask import Flask, current_app, g + logger = logging.getLogger(__name__) def get_db_connection() -> psycopg2.extensions.connection: """ diff --git a/kernelboard/lib/error.py b/kernelboard/lib/error.py index bbacbf0f..550c9faf 100644 --- a/kernelboard/lib/error.py +++ b/kernelboard/lib/error.py @@ -1,6 +1,6 @@ import http -from typing import List import logging +from typing import List logger = logging.getLogger(__name__) diff --git a/kernelboard/lib/file_handler.py b/kernelboard/lib/file_handler.py index 794c3b4e..2408e947 100644 --- a/kernelboard/lib/file_handler.py +++ b/kernelboard/lib/file_handler.py @@ -1,8 +1,15 @@ import ast -import re import mimetypes +import re + from werkzeug.utils import secure_filename -from kernelboard.lib.error import InvalidMimeError, InvalidSyntaxError,InvalidPythonExtensionError,MissingRequiredFieldError + +from kernelboard.lib.error import ( + InvalidMimeError, + InvalidPythonExtensionError, + InvalidSyntaxError, + MissingRequiredFieldError, +) ALLOWED_EXTS = {".py"} ALLOWED_PYTHON_MIMES = {"text/x-python", "text/x-script.python", "text/plain"} @@ -11,7 +18,10 @@ 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") + 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 "") diff --git a/kernelboard/lib/frontend_redirect.py b/kernelboard/lib/frontend_redirect.py index 953ae015..1ac0ae0c 100644 --- a/kernelboard/lib/frontend_redirect.py +++ b/kernelboard/lib/frontend_redirect.py @@ -1,3 +1,7 @@ +import os +from urllib.parse import urlparse + +from flask import redirect FRONTEND_ORIGIN = os.getenv("FRONTEND_ORIGIN", "/v2/") # e.g. "/", "/v2/", or "https://app.example.com" diff --git a/kernelboard/lib/rate_limiter.py b/kernelboard/lib/rate_limiter.py index b2294fd9..e391a5de 100644 --- a/kernelboard/lib/rate_limiter.py +++ b/kernelboard/lib/rate_limiter.py @@ -1,8 +1,9 @@ +import logging import os +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse + from flask_limiter import Limiter from flask_login import current_user -from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode -import logging logger = logging.getLogger(__name__) diff --git a/kernelboard/lib/redis_connection.py b/kernelboard/lib/redis_connection.py index d8d28097..68463450 100644 --- a/kernelboard/lib/redis_connection.py +++ b/kernelboard/lib/redis_connection.py @@ -1,4 +1,5 @@ import os + import redis diff --git a/kernelboard/lib/status_code.py b/kernelboard/lib/status_code.py index be49d0a0..0e2d27e3 100644 --- a/kernelboard/lib/status_code.py +++ b/kernelboard/lib/status_code.py @@ -1,6 +1,7 @@ -from flask import jsonify from http import HTTPStatus +from flask import jsonify + class HttpError(Exception): def __init__(self, message, status_code=500, code=None): diff --git a/kernelboard/og_tags.py b/kernelboard/og_tags.py index e4c5a53c..13664fea 100644 --- a/kernelboard/og_tags.py +++ b/kernelboard/og_tags.py @@ -5,6 +5,7 @@ """ import re + from flask import request # Common social media crawler User-Agent patterns diff --git a/pyproject.toml b/pyproject.toml index d3b4f168..4229e8af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,3 +9,11 @@ dependencies = [ [build-system] requires = ["flit_core<4"] build-backend = "flit_core.buildapi" + +[tool.ruff] +target-version = "py312" +line-length = 120 +exclude = ["migrations", "static", ".venv", "__pycache__"] + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] diff --git a/requirements.txt b/requirements.txt index 66107fcf..d6bfce0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,4 @@ urllib3>=2.4.0,<3.0.0 Werkzeug>=3.1.3,<3.2.0 pyyaml>=6.0.1 flask-limiter>=3.12 +ruff>=0.8.0 diff --git a/test_notification.py b/test_notification.py new file mode 100644 index 00000000..403a2d28 --- /dev/null +++ b/test_notification.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Temporarily fake a snapshot row to test Discord webhook notifications.""" +import psycopg2 +import os +import sys + +conn = psycopg2.connect(os.environ["DATABASE_URL"]) +cur = conn.cursor() + +if "--restore" in sys.argv: + # Just re-run the worker with --test to restore real data + print("Run: python3 ranking_worker.py --test") +else: + cur.execute(""" + UPDATE leaderboard.ranking_snapshot + SET user_id = 'fake_user_123', user_name = 'FakeUser' + WHERE (leaderboard_id, gpu_type, rank) = ( + SELECT leaderboard_id, gpu_type, rank + FROM leaderboard.ranking_snapshot + WHERE rank = 1 + LIMIT 1 + ) + """) + conn.commit() + print(f"Updated {cur.rowcount} row(s) to fake user") + print("Now run: python3 ranking_worker.py --test") + +conn.close() diff --git a/tests/api/test_auth_api.py b/tests/api/test_auth_api.py index 236ae81c..16ff70c6 100644 --- a/tests/api/test_auth_api.py +++ b/tests/api/test_auth_api.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, urlparse @@ -71,7 +71,7 @@ def test_callback_token_request_fails(client): with patch( "kernelboard.api.auth.requests.post", return_value=mock_response - ) as post: + ): response = client.get("/api/auth/discord/callback?state=123&code=456") assert_redirect_with_error(response, "token_error") @@ -118,7 +118,7 @@ def test_callback_userinfo_response_fails(client): with patch( "kernelboard.api.auth.requests.get", return_value=userinfo_response - ) as get: + ): response = client.get( "/api/auth/discord/callback?state=123&code=456" ) diff --git a/tests/api/test_news_api.py b/tests/api/test_news_api.py index 7db1231d..b2f8eadc 100644 --- a/tests/api/test_news_api.py +++ b/tests/api/test_news_api.py @@ -1,5 +1,5 @@ from http import HTTPStatus -from unittest.mock import patch, mock_open +from unittest.mock import mock_open, patch def test_news(client): @@ -19,7 +19,6 @@ def test_skip_invalid_yaml_with_mock(client): ), patch("builtins.open", mock_open(read_data=fake_file_content)): res = client.get("/api/news") assert res.status_code == HTTPStatus.NOT_FOUND - data = res.get_json() def test_only_return_valid_content_with_mock(client): diff --git a/tests/api/test_submission_api.py b/tests/api/test_submission_api.py index 88244536..3a4428dc 100644 --- a/tests/api/test_submission_api.py +++ b/tests/api/test_submission_api.py @@ -1,15 +1,17 @@ # tests/test_submission_api.py +import datetime as dt import http from io import BytesIO from types import SimpleNamespace -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + import flask_login -import datetime as dt -import requests import pytest -from kernelboard.lib.db import get_db_connection +import requests from psycopg2.extras import execute_values +from kernelboard.lib.db import get_db_connection + _TEST_USER_ID = "333" _TEST_WEB_AUTH_ID = "111" @@ -302,7 +304,7 @@ def test_submission_upstream_non_200_maps_to_http_error(app, client, prepare): 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: + with patch("kernelboard.api.submission.requests.post", return_value=error_response): resp = _post_submission(client) assert resp.status_code == http.HTTPStatus.BAD_REQUEST diff --git a/tests/conftest.py b/tests/conftest.py index bbf37054..3170a8b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,13 @@ -import psycopg2 -from kernelboard import create_app -import pytest import random +import secrets import string import subprocess import time -import secrets + +import psycopg2 +import pytest + +from kernelboard import create_app def get_test_redis_url(port: int): diff --git a/tests/test_db.py b/tests/test_db.py index a7056fc8..43f236b7 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,5 +1,6 @@ -import pytest import psycopg2 +import pytest + from kernelboard.lib.db import get_db_connection @@ -7,7 +8,7 @@ def test_get_and_close_db_connection(app): with app.app_context(): conn = get_db_connection() assert conn is not None - assert conn.closed == False + assert not conn.closed assert conn is get_db_connection() conn.cursor().execute("SELECT 1") diff --git a/tests/test_health.py b/tests/test_health.py index 40f3181a..8d3b6670 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -1,4 +1,5 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + import redis diff --git a/tests/test_index.py b/tests/test_index.py index 95548a96..0dc252e0 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,4 +1,5 @@ import re + from bs4 import BeautifulSoup diff --git a/tests/test_time.py b/tests/test_time.py index ef461fb5..5d2e0ca2 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,5 +1,6 @@ from datetime import datetime, timezone -from kernelboard.lib.time import to_time_left, _to_time_left, format_datetime + +from kernelboard.lib.time import _to_time_left, format_datetime, to_time_left def test_to_time_left(): @@ -34,7 +35,7 @@ def test_to_time_left(): assert to_time_left("1970-01-01 00:00:00+00:00") == "ended" - assert to_time_left("gibberish") == None + assert to_time_left("gibberish") is None def test_format_datetime():