Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,17 @@ export async function submitFile(form: FormData) {
return data; // e.g. { submission_id, message, ... }
}

export async function deleteSubmission(submissionId: number): Promise<void> {
const res = await fetch(`/api/submission/${submissionId}`, {
method: "DELETE",
});
if (!res.ok) {
const json = await res.json();
const message = json?.message || "Failed to delete submission";
throw new APIError(message, res.status);
}
}

export async function fetchUserSubmissions(
leaderboardId: number | string,
userId: number | string,
Expand Down
39 changes: 23 additions & 16 deletions frontend/src/pages/leaderboard/Leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,22 +191,26 @@ export default function Leaderboard() {
<Grid marginBottom={2}>
<Card>
<CardContent>
<CardTitle fontWeight="bold">Description</CardTitle>
<MarkdownRenderer content={data.description} />
{data.benchmarks && data.benchmarks.length > 0 && (
<details>
<summary style={{ cursor: "pointer", fontWeight: "bold", marginTop: 16 }}>
Benchmark Shapes
</summary>
<ul>
{data.benchmarks.map((b, i) => (
<li key={i}>
<code>{JSON.stringify(Object.fromEntries(Object.entries(b).filter(([k]) => k !== "seed")))}</code>
</li>
))}
</ul>
</details>
)}
<details>
<summary style={{ cursor: "pointer", fontWeight: "bold", fontSize: "1.5rem" }}>
Description
</summary>
<MarkdownRenderer content={data.description} />
{data.benchmarks && data.benchmarks.length > 0 && (
<details>
<summary style={{ cursor: "pointer", fontWeight: "bold", marginTop: 16 }}>
Benchmark Shapes
</summary>
<ul>
{data.benchmarks.map((b, i) => (
<li key={i}>
<code>{JSON.stringify(Object.fromEntries(Object.entries(b).filter(([k]) => k !== "seed")))}</code>
</li>
))}
</ul>
</details>
)}
</details>
</CardContent>
</Card>
</Grid>
Expand Down Expand Up @@ -236,6 +240,9 @@ export default function Leaderboard() {
rankings={data.rankings}
leaderboardId={id}
deadline={data.deadline}
onRefresh={() => {
if (id) call(id);
}}
/>
<Box sx={{ my: 4, borderTop: 1, borderColor: "divider" }} />
<Card>
Expand Down
125 changes: 122 additions & 3 deletions frontend/src/pages/leaderboard/components/RankingLists.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
Link as MuiLink,
Stack,
type SxProps,
type Theme,
Expand All @@ -12,7 +17,8 @@ import RankingTitleBadge from "./RankingTitleBadge";

import { formatMicroseconds } from "../../../lib/utils/ranking.ts";
import { getMedalIcon } from "../../../components/common/medal.tsx";
import { fetchCodes } from "../../../api/api.ts";
import { deleteSubmission, fetchCodes } from "../../../api/api.ts";
import CodeBlock from "../../../components/codeblock/CodeBlock";
import { CodeDialog } from "./CodeDialog.tsx";
import { isExpired } from "../../../lib/date/utils.ts";
import { useAuthStore } from "../../../lib/store/authStore.ts";
Expand All @@ -31,6 +37,7 @@ interface RankingsListProps {
rankings: Record<string, RankingItem[]>;
leaderboardId?: string;
deadline?: string;
onRefresh?: () => void;
}

const styles: Record<string, SxProps<Theme>> = {
Expand Down Expand Up @@ -86,6 +93,7 @@ export default function RankingsList({
rankings,
leaderboardId,
deadline,
onRefresh,
}: RankingsListProps) {
const expired = !!deadline && isExpired(deadline);
const me = useAuthStore((s) => s.me);
Expand All @@ -95,6 +103,30 @@ export default function RankingsList({
Math.random().toString(36).slice(2, 8),
);
const [codes, setCodes] = useState<Map<number, string>>(new Map());
const [selectedSubmission, setSelectedSubmission] = useState<{
id: number;
userName: string;
} | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);

const handleDelete = async () => {
if (!selectedSubmission) return;
setDeleting(true);
setDeleteError(null);
try {
await deleteSubmission(selectedSubmission.id);
setSelectedSubmission(null);
setConfirmDelete(false);
if (onRefresh) onRefresh();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Delete failed";
setDeleteError(msg);
} finally {
setDeleting(false);
}
};

const submissionIds = useMemo(() => {
if (!rankings) return [];
Expand Down Expand Up @@ -225,9 +257,23 @@ export default function RankingsList({
)}
{isAdmin && (
<Grid size={2}>
<Typography sx={styles.submissionId}>
<MuiLink
component="button"
variant="body2"
sx={{
...styles.submissionId,
cursor: "pointer",
textDecoration: "underline",
}}
onClick={() =>
setSelectedSubmission({
id: item.submission_id,
userName: item.user_name,
})
}
>
ID: {item.submission_id}
</Typography>
</MuiLink>
</Grid>
)}
</Grid>
Expand All @@ -236,6 +282,79 @@ export default function RankingsList({
</Box>
);
})}

{/* Admin submission detail + delete dialog */}
{isAdmin && selectedSubmission && (
<Dialog
open={!!selectedSubmission}
onClose={() => {
setSelectedSubmission(null);
setConfirmDelete(false);
setDeleteError(null);
}}
maxWidth="md"
fullWidth
>
<DialogTitle>
Submission #{selectedSubmission.id} by {selectedSubmission.userName}
</DialogTitle>
<DialogContent dividers>
{codes.get(selectedSubmission.id) ? (
<CodeBlock code={codes.get(selectedSubmission.id)!} />
) : (
<Typography color="text.secondary">
No code available for this submission.
</Typography>
)}
{deleteError && (
<Typography color="error" sx={{ mt: 2 }}>
Error: {deleteError}
</Typography>
)}
</DialogContent>
<DialogActions>
{!confirmDelete ? (
<>
<Button
onClick={() => {
setSelectedSubmission(null);
setDeleteError(null);
}}
>
Close
</Button>
<Button
color="error"
variant="contained"
onClick={() => setConfirmDelete(true)}
>
Delete Submission
</Button>
</>
) : (
<>
<Button
onClick={() => {
setConfirmDelete(false);
setDeleteError(null);
}}
disabled={deleting}
>
Cancel
</Button>
<Button
color="error"
variant="contained"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? "Deleting..." : "Confirm Delete"}
</Button>
</>
)}
</DialogActions>
</Dialog>
)}
</Stack>
);
}
67 changes: 67 additions & 0 deletions kernelboard/api/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,73 @@ def list_codes_route():
)


@submission_bp.route("/submission/<int:submission_id>", methods=["DELETE"])
@login_required
def delete_submission(submission_id):
"""
DELETE /api/submission/<submission_id>
Admin-only: deletes a submission by proxying to the cluster-manager admin endpoint.
"""
logger.info("[delete_submission] request for submission_id=%s", submission_id)

user_id, _ = get_id_and_username_from_session()
if not user_id:
return http_error(
message="user is not logged in",
status_code=http.HTTPStatus.UNAUTHORIZED,
)

whitelist = get_whitelist()
if user_id not in whitelist:
logger.warning(
"[delete_submission] non-admin user %s attempted delete on %s",
user_id,
submission_id,
)
return http_error(
message="forbidden: admin access required",
status_code=http.HTTPStatus.FORBIDDEN,
)

admin_token = os.getenv("ADMIN_TOKEN", "")
if not admin_token:
logger.error("[delete_submission] ADMIN_TOKEN is not set")
return http_error(
message="admin API not configured",
status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR,
)

base = get_cluster_manager_endpoint()
url = f"{base}/admin/submissions/{submission_id}"
headers = {"Authorization": f"Bearer {admin_token}"}

try:
resp = requests.delete(url, headers=headers, timeout=30)
except requests.RequestException as e:
logger.error("[delete_submission] forward failed: %s", e)
return http_error(
message=f"forward failed: {e}",
status_code=http.HTTPStatus.BAD_GATEWAY,
)

try:
payload = resp.json()
message = payload.get("message") or payload.get("detail") or resp.reason
if resp.status_code == 200:
return http_success(message=message, data=payload)
else:
return http_error(
message=message,
status_code=http.HTTPStatus(resp.status_code),
)
except Exception as e:
logger.error("[delete_submission] failed: %s", e)
return http_error(
message=str(e),
status_code=http.HTTPStatus.INTERNAL_SERVER_ERROR,
)


def check_admin_access_codes(
user_id: str, leaderboard_id: int, submission_ids: List[int]
):
Expand Down
1 change: 1 addition & 0 deletions kernelboard/lib/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def check_env_vars():
"DISCORD_CLIENT_ID": "preview-disabled",
"DISCORD_CLIENT_SECRET": "preview-disabled",
"DISCORD_CLUSTER_MANAGER_API_BASE_URL": "http://localhost:8080",
"ADMIN_TOKEN": "",
}

for var, default in optional_with_defaults.items():
Expand Down
Loading