VSCode Extension: Sidebar UI showing team's submissions, automatically updating and showing alerts as submissions are judged (#14)
* Add an Output Panel channel named "BWContest Log" * Allow client logout when no contest And make login/logout error messages clearer * Show contest name & team name in Code extension side panel * submission icons for sidebar panel * Start VSCode extension "onStartupFinished" instead of waiting for Sidebar to be opened * VSCode: Sidebar UI for up-to-date problem/submissions status - VSCode: poll API every 30 seconds to get contest metadata and all submission metadata for the logged in team - The Sidebar now shows all problems in the contest, along with their submissions and overall status, which automatically updates as submissions are submitted & judged - Web: "contestState" API to get all info for an activeTeam via their token - Update submit API to return the submission id, allowing the VSCode UI to immediately render it as Pending without waiting for a polling cycle - * Add "Compilation Failed" message to submissions that fail to build * Contest Import - Option to create repos & immediately activate the imported contest Useful for testing with old contests (including the submissions) * Test/Submit panel, use fixed-width font in input/output areas * Fix build error for 'pluralize' * Clear all state & halt polling loops on logout, restart them on login * Improve the debug fastPolling option - Toggleable via package.json config - Setting the option changes the initial state as well as ability to toggle states * Web project 'npm run format'
This commit is contained in:
parent
1c78b90643
commit
4a3ff56e7a
BIN
extension/bwcontest/media/SubmissionIcons/TeamPanel/correct.png
Normal file
BIN
extension/bwcontest/media/SubmissionIcons/TeamPanel/correct.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
After Width: | Height: | Size: 8.2 KiB |
BIN
extension/bwcontest/media/SubmissionIcons/TeamPanel/none.png
Normal file
BIN
extension/bwcontest/media/SubmissionIcons/TeamPanel/none.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.6 KiB |
BIN
extension/bwcontest/media/SubmissionIcons/TeamPanel/unknown.png
Normal file
BIN
extension/bwcontest/media/SubmissionIcons/TeamPanel/unknown.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.9 KiB |
@ -9,7 +9,7 @@
|
|||||||
"categories": [
|
"categories": [
|
||||||
"Other"
|
"Other"
|
||||||
],
|
],
|
||||||
"activationEvents": [],
|
"activationEvents": ["onStartupFinished"],
|
||||||
"main": "./out/main.js",
|
"main": "./out/main.js",
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"configuration": {
|
"configuration": {
|
||||||
@ -34,6 +34,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"description": "Path of java bin folder"
|
"description": "Path of java bin folder"
|
||||||
|
},
|
||||||
|
"BWContest.debugFastPolling": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Enables fast polling, with a command to toggle frequency"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -57,7 +62,12 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"commands": []
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "bwcontest.toggleFastPolling",
|
||||||
|
"title": "BWContest Developer: Toggle Fast Polling"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"vscode:prepublish": "npm run compile",
|
"vscode:prepublish": "npm run compile",
|
||||||
|
@ -3,30 +3,65 @@ import { getNonce } from './getNonce';
|
|||||||
import { cloneAndOpenRepo } from './extension';
|
import { cloneAndOpenRepo } from './extension';
|
||||||
import { BWPanel } from './problemPanel';
|
import { BWPanel } from './problemPanel';
|
||||||
import urlJoin from 'url-join';
|
import urlJoin from 'url-join';
|
||||||
|
import outputPanelLog from './outputPanelLog';
|
||||||
|
import { ContestStateForExtension, ProblemNameForExtension, SubmissionForExtension, SubmissionStateForExtension } from './contestMonitor/contestMonitorSharedTypes';
|
||||||
|
import { TeamData } from './sharedTypes';
|
||||||
|
import { ContestTeamState, getCachedContestTeamState, clearCachedContestTeamState, submissionsListChanged } from './contestMonitor/contestStateSyncManager';
|
||||||
|
import { startTeamStatusPolling, stopTeamStatusPolling } from './contestMonitor/pollingService';
|
||||||
|
|
||||||
export type ContestLanguage = 'Java' | 'CSharp' | 'CPP';
|
export type WebviewMessageType =
|
||||||
|
{ msg: 'onLogin'; data: TeamData } |
|
||||||
export type TeamData = {
|
{ msg: 'onLogout' } |
|
||||||
teamId: number;
|
{ msg: 'teamStatusUpdated'; data: SidebarTeamStatus | null };
|
||||||
contestId: number;
|
|
||||||
language: ContestLanguage;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebviewMessageType = { msg: 'onLogin'; data: TeamData } | { msg: 'onLogout' };
|
|
||||||
|
|
||||||
export type MessageType =
|
export type MessageType =
|
||||||
| { msg: 'onTestAndSubmit' }
|
| { msg: 'onTestAndSubmit' }
|
||||||
| { msg: 'onStartup' }
|
| { msg: 'onUIMount' }
|
||||||
| { msg: 'onClone'; data: { contestId: number; teamId: number } }
|
| { msg: 'onClone'; data: { contestId: number; teamId: number } }
|
||||||
| { msg: 'onLogin'; data: { teamName: string; password: string } }
|
| { msg: 'onLogin'; data: { teamName: string; password: string } }
|
||||||
| { msg: 'onLogout' };
|
| { msg: 'onLogout' };
|
||||||
|
|
||||||
|
export type SidebarTeamStatus = {
|
||||||
|
contestState: ContestStateForExtension;
|
||||||
|
correctProblems: SidebarProblemWithSubmissions[];
|
||||||
|
processingProblems: SidebarProblemWithSubmissions[];
|
||||||
|
incorrectProblems: SidebarProblemWithSubmissions[];
|
||||||
|
notStartedProblems: SidebarProblemWithSubmissions[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SidebarProblemWithSubmissions = {
|
||||||
|
problem: ProblemNameForExtension;
|
||||||
|
overallState: SubmissionStateForExtension | null;
|
||||||
|
submissions: SubmissionForExtension[];
|
||||||
|
modified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class SidebarProvider implements vscode.WebviewViewProvider {
|
export class SidebarProvider implements vscode.WebviewViewProvider {
|
||||||
|
private webview: vscode.Webview | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly extensionUri: vscode.Uri,
|
private readonly extensionUri: vscode.Uri,
|
||||||
private readonly context: vscode.ExtensionContext,
|
private readonly context: vscode.ExtensionContext,
|
||||||
private readonly webUrl: string
|
private readonly webUrl: string
|
||||||
) {}
|
) {
|
||||||
|
outputPanelLog.info("Constructing SidebarProvider");
|
||||||
|
|
||||||
|
const currentSubmissionsList = getCachedContestTeamState();
|
||||||
|
outputPanelLog.info("When SidebarProvider constructed, cached submission list is: " + JSON.stringify(currentSubmissionsList));
|
||||||
|
this.updateTeamStatus(currentSubmissionsList);
|
||||||
|
|
||||||
|
submissionsListChanged.add(submissionsChangedEventArgs => {
|
||||||
|
outputPanelLog.trace("Sidebar submission list updating from submissionsListChanged event");
|
||||||
|
|
||||||
|
if (!submissionsChangedEventArgs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateTeamStatus(
|
||||||
|
submissionsChangedEventArgs.contestTeamState,
|
||||||
|
submissionsChangedEventArgs.changedProblemIds
|
||||||
|
)});
|
||||||
|
}
|
||||||
|
|
||||||
private async handleLogin(
|
private async handleLogin(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
@ -42,47 +77,135 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
const resData = await res.json();
|
const resData = await res.json();
|
||||||
if (res.status !== 200 || resData.success !== true) {
|
if (res.status !== 200) {
|
||||||
|
outputPanelLog.error('Invalid Login: API returned ' + res.status);
|
||||||
|
vscode.window.showErrorMessage('BWContest: Login Failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resData.success !== true) {
|
||||||
|
outputPanelLog.error('Invalid Login attempt with message: ' + (resData.message ?? "<none>"));
|
||||||
vscode.window.showErrorMessage('BWContest: Invalid Login');
|
vscode.window.showErrorMessage('BWContest: Invalid Login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = resData.token;
|
const sessionToken = resData.token;
|
||||||
this.context.globalState.update('token', sessionToken);
|
|
||||||
const teamRes = await fetch(urlJoin(this.webUrl, `api/team/${sessionToken}`), {
|
const teamRes = await fetch(urlJoin(this.webUrl, `api/team/${sessionToken}`), {
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
});
|
});
|
||||||
const data2 = await teamRes.json();
|
const data2 = await teamRes.json();
|
||||||
if (!data2.success) {
|
if (!data2.success) {
|
||||||
|
outputPanelLog.error('Login attempt retrieved token but not team details. Staying logged out.');
|
||||||
|
vscode.window.showErrorMessage('BWContest: Invalid Login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.context.globalState.update('token', sessionToken);
|
||||||
this.context.globalState.update('teamData', data2.data);
|
this.context.globalState.update('teamData', data2.data);
|
||||||
|
|
||||||
|
startTeamStatusPolling();
|
||||||
|
|
||||||
|
outputPanelLog.info('Login succeeded');
|
||||||
webviewPostMessage({ msg: 'onLogin', data: data2.data });
|
webviewPostMessage({ msg: 'onLogin', data: data2.data });
|
||||||
|
|
||||||
|
const currentSubmissionsList = getCachedContestTeamState();
|
||||||
|
outputPanelLog.info("After login, cached submission list is: " + JSON.stringify(currentSubmissionsList));
|
||||||
|
this.updateTeamStatus(currentSubmissionsList);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleLogout(webviewPostMessage: (m: WebviewMessageType) => void) {
|
private async handleLogout(webviewPostMessage: (m: WebviewMessageType) => void) {
|
||||||
const sessionToken = this.context.globalState.get<string>('token');
|
const sessionToken = this.context.globalState.get<string>('token');
|
||||||
if (sessionToken === undefined) {
|
if (sessionToken === undefined) {
|
||||||
webviewPostMessage({ msg: 'onLogout' });
|
outputPanelLog.error("Team requested logout, but no token was stored locally. Switching to logged out state.");
|
||||||
|
this.clearLocalTeamDataAndFinishLogout(webviewPostMessage);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const teamData = this.context.globalState.get<TeamData>('teamData');
|
||||||
|
if (teamData === undefined) {
|
||||||
|
outputPanelLog.error("Team requested logout with a locally stored token but no teamData. Switching to logged out state.");
|
||||||
|
this.clearLocalTeamDataAndFinishLogout(webviewPostMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(urlJoin(this.webUrl, '/api/team/logout'), {
|
const res = await fetch(urlJoin(this.webUrl, '/api/team/logout'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
teamId: teamData.teamId,
|
||||||
token: sessionToken
|
token: sessionToken
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
|
outputPanelLog.error(`Team requested logout, failed with status code ${res.status}. Not modifying local state.`);
|
||||||
|
vscode.window.showErrorMessage(`BWContest: Logout failed with code ${res.status}`);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const data2 = await res.json();
|
||||||
|
const responseMessage = data2.message ? `Message: ${data2.message}` : '';
|
||||||
|
|
||||||
|
if (data2.success !== true) {
|
||||||
|
outputPanelLog.error(`Team requested logout, failed with normal status code. Not modifying local state. ` + responseMessage);
|
||||||
|
vscode.window.showErrorMessage(`BWContest: Logout failed.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data2 = await res.json();
|
|
||||||
if (data2.success === true) {
|
outputPanelLog.info(`Team requested logout, completed successfully. ` + responseMessage);
|
||||||
webviewPostMessage({ msg: 'onLogout' });
|
this.clearLocalTeamDataAndFinishLogout(webviewPostMessage);
|
||||||
this.context.globalState.update('token', undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearLocalTeamDataAndFinishLogout(webviewPostMessage: (m: WebviewMessageType) => void) {
|
||||||
|
webviewPostMessage({ msg: 'onLogout' });
|
||||||
|
|
||||||
|
stopTeamStatusPolling();
|
||||||
|
clearCachedContestTeamState();
|
||||||
|
|
||||||
|
this.context.globalState.update('token', undefined);
|
||||||
|
this.context.globalState.update('teamData', undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateTeamStatus(contestTeamState : ContestTeamState | null, changedProblemIds = new Set<number>) {
|
||||||
|
if (contestTeamState == null) {
|
||||||
|
outputPanelLog.trace("Not updating sidebar submission list because provided state is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.webview == null) {
|
||||||
|
outputPanelLog.trace("Not updating sidebar submission list because webview is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contestState = contestTeamState.contestState;
|
||||||
|
const problemsWithSubmissions = contestState.problems.map<SidebarProblemWithSubmissions>(p => ({
|
||||||
|
problem: p,
|
||||||
|
overallState: calculateOverallState(contestTeamState.submissionsList.get(p.id) ?? []),
|
||||||
|
submissions: contestTeamState.submissionsList.get(p.id) ?? [],
|
||||||
|
modified: changedProblemIds.has(p.id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const teamStatus: SidebarTeamStatus = {
|
||||||
|
contestState,
|
||||||
|
correctProblems: problemsWithSubmissions.filter(p => p.overallState === 'Correct'),
|
||||||
|
processingProblems: problemsWithSubmissions.filter(p => p.overallState === 'Processing'),
|
||||||
|
incorrectProblems: problemsWithSubmissions.filter(p => p.overallState === 'Incorrect'),
|
||||||
|
notStartedProblems: problemsWithSubmissions.filter(p => p.overallState === null),
|
||||||
|
}
|
||||||
|
|
||||||
|
const message: WebviewMessageType = {
|
||||||
|
msg: 'teamStatusUpdated',
|
||||||
|
data: teamStatus
|
||||||
|
};
|
||||||
|
|
||||||
|
outputPanelLog.trace("Posting teamStatusUpdated to webview with message: " + JSON.stringify(message));
|
||||||
|
this.webview.postMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resolveWebviewView(webviewView: vscode.WebviewView) {
|
public resolveWebviewView(webviewView: vscode.WebviewView) {
|
||||||
|
outputPanelLog.trace("SidebarProvider resolveWebviewView");
|
||||||
const webview = webviewView.webview;
|
const webview = webviewView.webview;
|
||||||
|
this.webview = webview;
|
||||||
webview.options = {
|
webview.options = {
|
||||||
enableScripts: true,
|
enableScripts: true,
|
||||||
localResourceRoots: [this.extensionUri]
|
localResourceRoots: [this.extensionUri]
|
||||||
@ -101,7 +224,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'onStartup': {
|
case 'onUIMount': {
|
||||||
|
outputPanelLog.trace("SidebarProvider onUIMount");
|
||||||
const token = this.context.globalState.get<string>('token');
|
const token = this.context.globalState.get<string>('token');
|
||||||
const teamData = this.context.globalState.get<TeamData>('teamData');
|
const teamData = this.context.globalState.get<TeamData>('teamData');
|
||||||
if (token !== undefined && teamData !== undefined) {
|
if (token !== undefined && teamData !== undefined) {
|
||||||
@ -109,6 +233,10 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||||||
msg: 'onLogin',
|
msg: 'onLogin',
|
||||||
data: teamData
|
data: teamData
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const currentSubmissionsList = getCachedContestTeamState();
|
||||||
|
outputPanelLog.trace("onUIMount, currentSubmissionsList is " + JSON.stringify(currentSubmissionsList));
|
||||||
|
this.updateTeamStatus(currentSubmissionsList);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -169,3 +297,17 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function calculateOverallState(submissions: SubmissionForExtension[]): SubmissionStateForExtension | null {
|
||||||
|
if (submissions.find(s => s.state === 'Correct')) {
|
||||||
|
return 'Correct';
|
||||||
|
}
|
||||||
|
else if (submissions.find(s => s.state === 'Processing')) {
|
||||||
|
return 'Processing';
|
||||||
|
}
|
||||||
|
else if (submissions.find(s => s.state === 'Incorrect')) {
|
||||||
|
return 'Incorrect';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
export type FullStateForExtension = {
|
||||||
|
contestState: ContestStateForExtension,
|
||||||
|
submissions: SubmissionForExtension[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProblemNameForExtension = {
|
||||||
|
id: number,
|
||||||
|
friendlyName: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContestStateForExtension = {
|
||||||
|
startTime: Date | null,
|
||||||
|
endTime: Date | null,
|
||||||
|
problems: ProblemNameForExtension[],
|
||||||
|
isActive: boolean,
|
||||||
|
isScoreboardFrozen: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubmissionStateForExtension = 'Processing' | 'Correct' | 'Incorrect';
|
||||||
|
|
||||||
|
export type SubmissionForExtension = {
|
||||||
|
id: number,
|
||||||
|
contestId: number,
|
||||||
|
teamId: number,
|
||||||
|
problemId: number,
|
||||||
|
createdAt: Date,
|
||||||
|
state: SubmissionStateForExtension
|
||||||
|
message: string | null
|
||||||
|
}
|
@ -0,0 +1,170 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import urlJoin from 'url-join';
|
||||||
|
import outputPanelLog from '../outputPanelLog';
|
||||||
|
import { extensionSettings } from '../extension';
|
||||||
|
import { ContestStateForExtension, ProblemNameForExtension, FullStateForExtension, SubmissionForExtension } from './contestMonitorSharedTypes';
|
||||||
|
import { LiteEvent } from '../utilities/LiteEvent';
|
||||||
|
|
||||||
|
export type ContestTeamState = {
|
||||||
|
contestState: ContestStateForExtension,
|
||||||
|
submissionsList: Map<number, SubmissionForExtension[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubmissionListStateChangedEventArgs = {
|
||||||
|
contestTeamState: ContestTeamState,
|
||||||
|
changedProblemIds: Set<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
let latestContestTeamState: ContestTeamState | null = null;
|
||||||
|
|
||||||
|
export function getCachedContestTeamState(): ContestTeamState | null {
|
||||||
|
return latestContestTeamState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCachedContestTeamState(): void {
|
||||||
|
latestContestTeamState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmissionsListChanged = new LiteEvent<SubmissionListStateChangedEventArgs>();
|
||||||
|
export const submissionsListChanged = onSubmissionsListChanged.expose();
|
||||||
|
|
||||||
|
let latestPollNum = 0;
|
||||||
|
export async function pollContestStatus(context: vscode.ExtensionContext) {
|
||||||
|
const pollNum = ++latestPollNum;
|
||||||
|
outputPanelLog.trace(`Polling contest status, poll #${pollNum}`);
|
||||||
|
|
||||||
|
const sessionToken = context.globalState.get('token');
|
||||||
|
if (!sessionToken) {
|
||||||
|
outputPanelLog.trace(` Ending poll #${pollNum}: No sessionToken`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contestStateResponse = await fetch(urlJoin(extensionSettings().webUrl, `api/team/${sessionToken}/contestState`), {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (contestStateResponse.status != 200) {
|
||||||
|
outputPanelLog.trace(` Ending poll #${pollNum}: Status check API returned http status ${contestStateResponse.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await contestStateResponse.json();
|
||||||
|
if (!data.success) {
|
||||||
|
outputPanelLog.trace(` Ending poll #${pollNum}: Status check returned OK but was not successful`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullState: FullStateForExtension = data.data;
|
||||||
|
outputPanelLog.trace(` Poll #${pollNum} succeeded. Submission count: ${fullState.submissions.length}. Diffing...`);
|
||||||
|
|
||||||
|
diffAndUpdateContestState(fullState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffAndUpdateContestState(fullState: FullStateForExtension) {
|
||||||
|
const contestState = fullState.contestState;
|
||||||
|
const currentSubmissionsList = createProblemSubmissionsLookup(contestState.problems, fullState.submissions);
|
||||||
|
const changedProblemIds = new Set<number>();
|
||||||
|
|
||||||
|
let anythingChanged = false;
|
||||||
|
if (latestContestTeamState == null) {
|
||||||
|
outputPanelLog.trace(` No previously cached data to diff`);
|
||||||
|
anythingChanged = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
for (const problem of contestState.problems) {
|
||||||
|
const problemId = problem.id;
|
||||||
|
const currentSubmissionsForProblem = currentSubmissionsList.get(problemId) ?? [];
|
||||||
|
const cachedSubmissionsForProblem = latestContestTeamState.submissionsList.get(problemId) ?? [];
|
||||||
|
|
||||||
|
const currentSubmissionsAlreadyInCache = currentSubmissionsForProblem!.filter(s => cachedSubmissionsForProblem.find(ss => ss.id == s.id));
|
||||||
|
const currentSubmissionsNotInCache = currentSubmissionsForProblem!.filter(s => !cachedSubmissionsForProblem.find(ss => ss.id == s.id));
|
||||||
|
const cachedSubmissionsNotInCurrent = cachedSubmissionsForProblem.filter(s => !currentSubmissionsForProblem!.find(ss => ss.id == s.id));
|
||||||
|
|
||||||
|
for (const currentSubmission of currentSubmissionsAlreadyInCache ) {
|
||||||
|
const previousSubmission = cachedSubmissionsForProblem.find(s => s.id == currentSubmission.id)!;
|
||||||
|
if (currentSubmission.state != previousSubmission.state) {
|
||||||
|
anythingChanged = true;
|
||||||
|
changedProblemIds.add(problem.id);
|
||||||
|
outputPanelLog.trace(` Submission state for #${currentSubmission.id} changed from ${previousSubmission.state} (message '${previousSubmission.message}') to ${currentSubmission.state} (message '${currentSubmission.message}')`);
|
||||||
|
alertForNewState(problem, currentSubmission);
|
||||||
|
} else if (currentSubmission.message != previousSubmission.message) {
|
||||||
|
anythingChanged = true;
|
||||||
|
changedProblemIds.add(problem.id);
|
||||||
|
outputPanelLog.trace(` Submission message changed (with same state) for #${currentSubmission.id} from ${previousSubmission.message} to ${currentSubmission.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const currentSubmission of currentSubmissionsNotInCache ) {
|
||||||
|
anythingChanged = true;
|
||||||
|
changedProblemIds.add(problem.id);
|
||||||
|
outputPanelLog.trace(` Newly acknowledge submission #${currentSubmission.id} with state ${currentSubmission.state} and message ${currentSubmission.message}`);
|
||||||
|
alertForNewState(problem, currentSubmission);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const previousSubmission of cachedSubmissionsNotInCurrent ) {
|
||||||
|
anythingChanged = true;
|
||||||
|
outputPanelLog.trace(` Deleted submission #${previousSubmission.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPanelLog.trace(anythingChanged ? " Diff has changes, triggering events" : " No changes found");
|
||||||
|
|
||||||
|
if (anythingChanged) {
|
||||||
|
latestContestTeamState = { contestState, submissionsList: currentSubmissionsList};
|
||||||
|
onSubmissionsListChanged.trigger({
|
||||||
|
contestTeamState: latestContestTeamState,
|
||||||
|
changedProblemIds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProblemSubmissionsLookup(problems: ProblemNameForExtension[], submissions: SubmissionForExtension[]): Map<number, SubmissionForExtension[]> {
|
||||||
|
const orderedSubmissionsByProblemId = new Map<number, SubmissionForExtension[]>();
|
||||||
|
for (const problem of problems) {
|
||||||
|
orderedSubmissionsByProblemId.set(problem.id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const submission of submissions.sort(s => s.id)) {
|
||||||
|
orderedSubmissionsByProblemId.get(submission.problemId)!.push(submission);
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedSubmissionsByProblemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertForNewState(problem: ProblemNameForExtension, currentSubmission: SubmissionForExtension) {
|
||||||
|
// Only alert on state changes team cares about
|
||||||
|
if (currentSubmission.state === 'Correct') {
|
||||||
|
vscode.window.showInformationMessage(`BWContest Judge: CORRECT Submission '${problem.friendlyName}'`);
|
||||||
|
} else if (currentSubmission.state === 'Incorrect') {
|
||||||
|
const messageDisplayText = currentSubmission.message ? `Message: ${currentSubmission.message}` : '';
|
||||||
|
vscode.window.showInformationMessage(`BWContest Judge: INCORRECT Submission '${problem.friendlyName}' ${messageDisplayText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordInitialSubmission(submission: SubmissionForExtension): void {
|
||||||
|
outputPanelLog.trace("Server received new submission, #" + submission.id);
|
||||||
|
|
||||||
|
if (!latestContestTeamState) {
|
||||||
|
outputPanelLog.trace(" No locally cached submission list state, the normal polling cycle will update the list");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSubmissionListForProblem = latestContestTeamState.submissionsList.get(submission.problemId);
|
||||||
|
if (existingSubmissionListForProblem === undefined) {
|
||||||
|
outputPanelLog.trace(` The cached submission list does not know about problemId #${submission.problemId}. Next polling cycle should fix consistency.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingSubmissionListForProblem.find(s => s.id == submission.id)) {
|
||||||
|
outputPanelLog.trace(` The cached submission list already knows about submissionId #${submission.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPanelLog.trace(` New submission #${submission.id} added to cache, triggering events`);
|
||||||
|
existingSubmissionListForProblem.push(submission);
|
||||||
|
onSubmissionsListChanged.trigger({
|
||||||
|
contestTeamState: latestContestTeamState,
|
||||||
|
changedProblemIds: new Set<number>([submission.problemId]),
|
||||||
|
});
|
||||||
|
}
|
66
extension/bwcontest/src/contestMonitor/pollingService.ts
Normal file
66
extension/bwcontest/src/contestMonitor/pollingService.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import outputPanelLog from '../outputPanelLog';
|
||||||
|
import { sleep } from '../utilities/sleep';
|
||||||
|
import { pollContestStatus } from './contestStateSyncManager';
|
||||||
|
import { SimpleCancellationToken } from '../utilities/SimpleCancellationToken';
|
||||||
|
|
||||||
|
let extensionContext: vscode.ExtensionContext;
|
||||||
|
|
||||||
|
let currentlyPolling = false;
|
||||||
|
let currentPollingCancellationToken: SimpleCancellationToken | null = null;
|
||||||
|
let debugPollingLoopNum = 0;
|
||||||
|
|
||||||
|
const defaultPollingIntervalSeconds = 30;
|
||||||
|
const developerFastPollingIntervalSeconds = 5;
|
||||||
|
let pollingIntervalSeconds = defaultPollingIntervalSeconds;
|
||||||
|
|
||||||
|
export async function startTeamStatusPollingOnActivation(context: vscode.ExtensionContext) {
|
||||||
|
extensionContext = context;
|
||||||
|
|
||||||
|
outputPanelLog.info(`Extension activated, try starting polling loop`);
|
||||||
|
await startTeamStatusPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startTeamStatusPolling() {
|
||||||
|
if (currentlyPolling) {
|
||||||
|
outputPanelLog.trace("Tried to start team status polling, but it's already running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (!extensionContext.globalState.get('token')) {
|
||||||
|
outputPanelLog.info("Tried to start team status polling, but team is not logged in.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentlyPolling = true;
|
||||||
|
currentPollingCancellationToken = new SimpleCancellationToken();
|
||||||
|
startPollingWorker(currentPollingCancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPollingWorker(cancellationToken: SimpleCancellationToken) {
|
||||||
|
const pollingLoopNum = ++debugPollingLoopNum;
|
||||||
|
outputPanelLog.trace(`Starting polling loop #${pollingLoopNum}, checking contest/team status every ${pollingIntervalSeconds} seconds`);
|
||||||
|
|
||||||
|
while (!cancellationToken.isCancelled) {
|
||||||
|
try {
|
||||||
|
await pollContestStatus(extensionContext);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
outputPanelLog.error("Polling contest status failed: " + (error ?? "<unknown error>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(pollingIntervalSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPanelLog.trace(`Polling loop #${pollingLoopNum} halting, cancellationToken was cancelled`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopTeamStatusPolling() {
|
||||||
|
outputPanelLog.trace("Stopping team status polling");
|
||||||
|
currentPollingCancellationToken?.cancel();
|
||||||
|
currentlyPolling = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFastPolling(enabled: boolean): void {
|
||||||
|
pollingIntervalSeconds = enabled ? developerFastPollingIntervalSeconds : defaultPollingIntervalSeconds;
|
||||||
|
outputPanelLog.info(`Changed polling interval to ${pollingIntervalSeconds} seconds. Takes effect after current delay.`);
|
||||||
|
}
|
@ -5,12 +5,15 @@ import urlJoin from 'url-join';
|
|||||||
import git from 'isomorphic-git';
|
import git from 'isomorphic-git';
|
||||||
import path = require('path');
|
import path = require('path');
|
||||||
import http from 'isomorphic-git/http/node';
|
import http from 'isomorphic-git/http/node';
|
||||||
|
import outputPanelLog from './outputPanelLog';
|
||||||
|
import { startTeamStatusPollingOnActivation, stopTeamStatusPolling, useFastPolling } from './contestMonitor/pollingService';
|
||||||
|
|
||||||
export interface BWContestSettings {
|
export interface BWContestSettings {
|
||||||
repoBaseUrl: string;
|
repoBaseUrl: string;
|
||||||
webUrl: string;
|
webUrl: string;
|
||||||
repoClonePath: string;
|
repoClonePath: string;
|
||||||
javaPath: string;
|
javaPath: string;
|
||||||
|
debugFastPolling: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extensionSettings(): BWContestSettings {
|
export function extensionSettings(): BWContestSettings {
|
||||||
@ -77,8 +80,16 @@ export async function cloneAndOpenRepo(contestId: number, teamId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dir = path.join(currentSettings.repoClonePath, 'BWContest', contestId.toString(), repoName);
|
const dir = path.join(currentSettings.repoClonePath, 'BWContest', contestId.toString(), repoName);
|
||||||
|
outputPanelLog.info(`Running 'git clone' to directory: ${dir}`);
|
||||||
|
try {
|
||||||
await git.clone({ fs, http, dir, url: repoUrl });
|
await git.clone({ fs, http, dir, url: repoUrl });
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
outputPanelLog.error("Failed to 'git clone'. The git server might be incorrectly configured. Error: " + error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPanelLog.info("Closing workspaces...");
|
||||||
closeAllWorkspaces();
|
closeAllWorkspaces();
|
||||||
|
|
||||||
const addedFolder = vscode.workspace.updateWorkspaceFolders(
|
const addedFolder = vscode.workspace.updateWorkspaceFolders(
|
||||||
@ -96,14 +107,35 @@ export async function cloneAndOpenRepo(contestId: number, teamId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
|
outputPanelLog.info("BWContest Extension Activated");
|
||||||
|
|
||||||
const sidebarProvider = new SidebarProvider(
|
const sidebarProvider = new SidebarProvider(
|
||||||
context.extensionUri,
|
context.extensionUri,
|
||||||
context,
|
context,
|
||||||
extensionSettings().webUrl
|
extensionSettings().webUrl
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let fastPolling = extensionSettings().debugFastPolling;
|
||||||
|
useFastPolling(fastPolling);
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.window.registerWebviewViewProvider('bwcontest-sidebar', sidebarProvider)
|
vscode.window.registerWebviewViewProvider('bwcontest-sidebar', sidebarProvider),
|
||||||
);
|
vscode.commands.registerCommand('bwcontest.toggleFastPolling', () => {
|
||||||
|
if (!extensionSettings().debugFastPolling) {
|
||||||
|
outputPanelLog.trace("Tried to toggle fast polling, but not allowed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fastPolling = !fastPolling;
|
||||||
|
useFastPolling(fastPolling);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
startTeamStatusPollingOnActivation(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivate() {
|
||||||
|
outputPanelLog.info("BWContest Extension Deactivated");
|
||||||
|
stopTeamStatusPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate() {}
|
|
||||||
|
7
extension/bwcontest/src/outputPanelLog.ts
Normal file
7
extension/bwcontest/src/outputPanelLog.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { window } from "vscode";
|
||||||
|
|
||||||
|
/** Logs to the Output panel of a team's VS Code instance. Useful for diagnosing issues.
|
||||||
|
*
|
||||||
|
* Do NOT output anything secret here. */
|
||||||
|
const outputPanelLog = window.createOutputChannel('BWContest Log', {log: true});
|
||||||
|
export default outputPanelLog;
|
@ -4,10 +4,12 @@ import urlJoin from 'url-join';
|
|||||||
import { extensionSettings } from './extension';
|
import { extensionSettings } from './extension';
|
||||||
import { runJava } from './run/java';
|
import { runJava } from './run/java';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { TeamData } from './SidebarProvider';
|
|
||||||
import { submitProblem } from './submit';
|
import { submitProblem } from './submit';
|
||||||
import { runCSharp } from './run/csharp';
|
import { runCSharp } from './run/csharp';
|
||||||
import { runCpp } from './run/cpp';
|
import { runCpp } from './run/cpp';
|
||||||
|
import { TeamData } from './sharedTypes';
|
||||||
|
import outputPanelLog from './outputPanelLog';
|
||||||
|
import { recordInitialSubmission } from './contestMonitor/contestStateSyncManager';
|
||||||
|
|
||||||
export type ProblemData = {
|
export type ProblemData = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -54,6 +56,7 @@ export class BWPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static show(context: vscode.ExtensionContext, webUrl: string) {
|
public static show(context: vscode.ExtensionContext, webUrl: string) {
|
||||||
|
outputPanelLog.info("Showing BWPanel");
|
||||||
const column = vscode.window.activeTextEditor
|
const column = vscode.window.activeTextEditor
|
||||||
? vscode.window.activeTextEditor.viewColumn
|
? vscode.window.activeTextEditor.viewColumn
|
||||||
: undefined;
|
: undefined;
|
||||||
@ -117,20 +120,27 @@ export class BWPanel {
|
|||||||
}
|
}
|
||||||
await vscode.workspace.saveAll();
|
await vscode.workspace.saveAll();
|
||||||
const ans = await vscode.window.showInformationMessage(
|
const ans = await vscode.window.showInformationMessage(
|
||||||
`Are you sure you want to submit ${problem.name}?`,
|
`Are you sure you want to submit '${problem.name}'?`,
|
||||||
'Yes',
|
'Yes',
|
||||||
'No'
|
'No'
|
||||||
);
|
);
|
||||||
if (ans !== 'Yes') {
|
if (ans !== 'Yes') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
submitProblem(sessionToken, teamData.contestId, teamData.teamId, problemId).then((result) => {
|
|
||||||
if (result.success === true) {
|
try {
|
||||||
vscode.window.showInformationMessage('Submitted!');
|
const submissionResult = await submitProblem(sessionToken, teamData.contestId, teamData.teamId, problemId);
|
||||||
|
if (submissionResult.success === true) {
|
||||||
|
recordInitialSubmission(submissionResult.submission);
|
||||||
|
vscode.window.showInformationMessage(`Submitted '${problem.name}'!`);
|
||||||
} else {
|
} else {
|
||||||
vscode.window.showErrorMessage(`Error: ${result.message}`);
|
vscode.window.showErrorMessage(`Error submitting '${problem.name}': ${submissionResult.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
vscode.window.showErrorMessage(`Web error submitting '${problem.name}'`);
|
||||||
|
outputPanelLog.error(`Web error submitting '${problem.name}': ${error}`);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRun(problemId: number, input: string) {
|
private async handleRun(problemId: number, input: string) {
|
||||||
|
9
extension/bwcontest/src/sharedTypes.ts
Normal file
9
extension/bwcontest/src/sharedTypes.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export type ContestLanguage = 'Java' | 'CSharp' | 'CPP';
|
||||||
|
|
||||||
|
export type TeamData = {
|
||||||
|
teamId: number;
|
||||||
|
teamName: string;
|
||||||
|
contestId: number;
|
||||||
|
contestName: string;
|
||||||
|
language: ContestLanguage;
|
||||||
|
};
|
@ -4,24 +4,37 @@ import git from 'isomorphic-git';
|
|||||||
import path = require('path');
|
import path = require('path');
|
||||||
import http from 'isomorphic-git/http/node';
|
import http from 'isomorphic-git/http/node';
|
||||||
import urlJoin from 'url-join';
|
import urlJoin from 'url-join';
|
||||||
|
import outputPanelLog from './outputPanelLog';
|
||||||
|
import { SubmissionForExtension } from './contestMonitor/contestMonitorSharedTypes';
|
||||||
|
|
||||||
export async function submitProblem(
|
export async function submitProblem(
|
||||||
sessionToken: string,
|
sessionToken: string,
|
||||||
contestId: number,
|
contestId: number,
|
||||||
teamId: number,
|
teamId: number,
|
||||||
problemId: number
|
problemId: number
|
||||||
): Promise<{ success: true } | { success: false; message: string }> {
|
): Promise<{ success: true; submission: SubmissionForExtension } | { success: false; message: string }> {
|
||||||
|
outputPanelLog.info(`Submitting problem id #{${problemId}}...`);
|
||||||
|
|
||||||
|
let hash: string;
|
||||||
|
let repoDir: string;
|
||||||
|
|
||||||
|
try {
|
||||||
const repoClonePath = extensionSettings().repoClonePath;
|
const repoClonePath = extensionSettings().repoClonePath;
|
||||||
|
|
||||||
const repoDir = path.join(repoClonePath, 'BWContest', contestId.toString(), teamId.toString());
|
repoDir = path.join(repoClonePath, 'BWContest', contestId.toString(), teamId.toString());
|
||||||
await git.add({ fs, dir: repoDir, filepath: '.' });
|
await git.add({ fs, dir: repoDir, filepath: '.' });
|
||||||
|
|
||||||
const hash = await git.commit({
|
hash = await git.commit({
|
||||||
fs,
|
fs,
|
||||||
dir: repoDir,
|
dir: repoDir,
|
||||||
author: { name: `Team ${teamId}` },
|
author: { name: `Team ${teamId}` },
|
||||||
message: `Submit problem ${problemId}`
|
message: `Submit problem ${problemId}`
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
outputPanelLog.error("Fail to make commit for submission: " + JSON.stringify(error));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await git.push({
|
const result = await git.push({
|
||||||
@ -54,5 +67,6 @@ export async function submitProblem(
|
|||||||
if (resData.success !== true) {
|
if (resData.success !== true) {
|
||||||
return { success: false, message: resData.message };
|
return { success: false, message: resData.message };
|
||||||
}
|
}
|
||||||
return { success: true };
|
|
||||||
|
return { success: true, submission: resData.submission };
|
||||||
}
|
}
|
||||||
|
30
extension/bwcontest/src/utilities/LiteEvent.ts
Normal file
30
extension/bwcontest/src/utilities/LiteEvent.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// Modified from JasonKleban @ https://gist.github.com/JasonKleban/50cee44960c225ac1993c922563aa540
|
||||||
|
|
||||||
|
export { ILiteEvent, LiteEvent }
|
||||||
|
|
||||||
|
interface ILiteEvent<T> {
|
||||||
|
add(handler: { (data?: T): void }): void;
|
||||||
|
remove(handler: { (data?: T): void }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiteEvent<T> implements ILiteEvent<T> {
|
||||||
|
protected handlers: { (data?: T): void; }[] = [];
|
||||||
|
|
||||||
|
public add(handler: { (data?: T): void }): void {
|
||||||
|
this.handlers.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public remove(handler: { (data?: T): void }): boolean {
|
||||||
|
const countBefore = this.handlers.length;
|
||||||
|
this.handlers = this.handlers.filter(h => h !== handler);
|
||||||
|
return countBefore != this.handlers.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public trigger(data?: T) {
|
||||||
|
this.handlers.slice(0).forEach(h => h(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public expose(): ILiteEvent<T> {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
export class SimpleCancellationToken {
|
||||||
|
private _isCancelled: boolean = false;
|
||||||
|
get isCancelled() { return this._isCancelled; }
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this._isCancelled = true;
|
||||||
|
}
|
||||||
|
}
|
3
extension/bwcontest/src/utilities/sleep.ts
Normal file
3
extension/bwcontest/src/utilities/sleep.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
@ -107,7 +107,7 @@
|
|||||||
<div style="display:flex">
|
<div style="display:flex">
|
||||||
<div style="flex:1; margin-right:20px">
|
<div style="flex:1; margin-right:20px">
|
||||||
<h3>Sample Input (You can edit this!)</h3>
|
<h3>Sample Input (You can edit this!)</h3>
|
||||||
<textarea bind:value={sampleInputValue} />
|
<textarea class="inputOutputArea" bind:value={sampleInputValue} />
|
||||||
<button style="margin-top:5px" on:click={resetInput} type="button">Reset Input</button>
|
<button style="margin-top:5px" on:click={resetInput} type="button">Reset Input</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1">
|
<div style="flex:1">
|
||||||
@ -117,7 +117,7 @@
|
|||||||
<span class="loader"></span>
|
<span class="loader"></span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<textarea bind:value={outputValue} readonly />
|
<textarea class="inputOutputArea" bind:value={outputValue} readonly />
|
||||||
{#if !running}
|
{#if !running}
|
||||||
<button style="margin-top:5px" on:click={onRun} type="button">Run</button>
|
<button style="margin-top:5px" on:click={onRun} type="button">Run</button>
|
||||||
{:else}
|
{:else}
|
||||||
@ -165,6 +165,10 @@
|
|||||||
animation: rotation 1s linear infinite;
|
animation: rotation 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inputOutputArea {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes rotation {
|
@keyframes rotation {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
|
@ -1,17 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { WebviewMessageType, MessageType, TeamData } from '../../src/SidebarProvider';
|
import SidebarProblemStatus from './SidebarProblemStatus.svelte';
|
||||||
|
import type { TeamData } from '../../src/sharedTypes';
|
||||||
function postMessage(message: MessageType) {
|
import type { WebviewMessageType, MessageType, SidebarTeamStatus } from '../../src/SidebarProvider';
|
||||||
vscode.postMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
let teamname: string;
|
let teamname: string;
|
||||||
let password: string;
|
let password: string;
|
||||||
|
|
||||||
let loggedIn = false;
|
let loggedIn = false;
|
||||||
|
|
||||||
let teamData: TeamData | undefined;
|
let teamData: TeamData | null = null;
|
||||||
|
let teamStatus: SidebarTeamStatus | null = null;
|
||||||
|
|
||||||
|
let totalProblems = 0;
|
||||||
|
|
||||||
|
function postMessage(message: MessageType) {
|
||||||
|
vscode.postMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
function onClone() {
|
function onClone() {
|
||||||
if (teamData) {
|
if (teamData) {
|
||||||
@ -34,7 +39,7 @@
|
|||||||
msg: 'onLogout'
|
msg: 'onLogout'
|
||||||
});
|
});
|
||||||
loggedIn = false;
|
loggedIn = false;
|
||||||
teamData = undefined;
|
teamData = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTestAndSubmit() {
|
function onTestAndSubmit() {
|
||||||
@ -42,7 +47,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
postMessage({ msg: 'onStartup' });
|
postMessage({ msg: 'onUIMount' });
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
@ -50,30 +55,190 @@
|
|||||||
if (m.msg === 'onLogin') {
|
if (m.msg === 'onLogin') {
|
||||||
loggedIn = true;
|
loggedIn = true;
|
||||||
teamData = m.data;
|
teamData = m.data;
|
||||||
|
teamStatus = null;
|
||||||
} else if (m.msg === 'onLogout') {
|
} else if (m.msg === 'onLogout') {
|
||||||
// loggedIn = false;
|
loggedIn = false;
|
||||||
// teamData = undefined;
|
teamStatus = null;
|
||||||
|
} else if (m.msg === 'teamStatusUpdated') {
|
||||||
|
teamStatus = m.data;
|
||||||
|
totalProblems = teamStatus
|
||||||
|
? teamStatus.correctProblems.length +
|
||||||
|
teamStatus.processingProblems.length +
|
||||||
|
teamStatus.incorrectProblems.length +
|
||||||
|
teamStatus.notStartedProblems.length
|
||||||
|
: 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>Contest</h1>
|
|
||||||
|
|
||||||
{#if !loggedIn}
|
{#if !loggedIn}
|
||||||
|
<h1>Contest Login</h1>
|
||||||
<label for="teamname">Team Name</label>
|
<label for="teamname">Team Name</label>
|
||||||
<input bind:value={teamname} id="teamname" type="text" />
|
<input bind:value={teamname} id="teamname" type="text" />
|
||||||
|
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input bind:value={password} id="password" type="password" />
|
<input bind:value={password} id="password" type="password" />
|
||||||
|
|
||||||
|
<div class="buttonContainer">
|
||||||
<button on:click={onLogin}>Login</button>
|
<button on:click={onLogin}>Login</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<button on:click={onLogout}>Logout</button>
|
<h2 class="sidebarSectionHeader">Contest Info</h2>
|
||||||
{#if teamData}
|
{#if teamData}
|
||||||
<p>Language: {teamData.language}</p>
|
<div class="sidebarSection">
|
||||||
<p>TeamID: {teamData.teamId}</p>
|
<p>
|
||||||
<p>ContestID: {teamData.contestId}</p>
|
<span class="infoLabel">Team:</span>
|
||||||
<button on:click={onClone}>Clone and Open Repo</button>
|
<span class="infoData">{teamData.teamName}</span>
|
||||||
<button on:click={onTestAndSubmit}>Test & Submit</button>
|
<span class="extraInfo"> (#{teamData.teamId})</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="infoLabel">Contest:</span>
|
||||||
|
<span class="infoData">{teamData.contestName}</span>
|
||||||
|
<span class="extraInfo"> (#{teamData.contestId})</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="infoLabel">Language:</span>
|
||||||
|
<span class="infoData">{teamData.language}</span>
|
||||||
|
</p>
|
||||||
|
<div class="buttonContainer">
|
||||||
|
<button on:click={onLogout} class="sidebarButton">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="sidebarSectionHeader">Actions</h2>
|
||||||
|
<div class="sidebarSection">
|
||||||
|
<div class="buttonContainer">
|
||||||
|
<button on:click={onClone} class="sidebarButton">Clone and Open Repo</button>
|
||||||
|
<button on:click={onTestAndSubmit} class="sidebarButton">Test & Submit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="sidebarSectionHeader">Problem Progress</h2>
|
||||||
|
<div class="sidebarSection">
|
||||||
|
{#if teamStatus}
|
||||||
|
<div class="problemResultsSection">
|
||||||
|
<div>
|
||||||
|
<span class="problemResultsSectionHeader inProgress">Pending Judgment </span>
|
||||||
|
</div>
|
||||||
|
{#if teamStatus.processingProblems.length > 0}
|
||||||
|
{#each teamStatus.processingProblems as inProgressProblem (JSON.stringify(inProgressProblem))}
|
||||||
|
<SidebarProblemStatus problem={inProgressProblem} contestState={teamStatus.contestState} />
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="problemSectionExplanation">No pending submissions</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="problemResultsSection">
|
||||||
|
<div>
|
||||||
|
<span class="problemResultsSectionHeader correct">Correct </span>
|
||||||
|
<span class="problemResultsSectionCount">{teamStatus.correctProblems.length} of {totalProblems}</span>
|
||||||
|
</div>
|
||||||
|
{#if teamStatus.correctProblems.length > 0}
|
||||||
|
{#each teamStatus.correctProblems as correctProblem (JSON.stringify(correctProblem))}
|
||||||
|
<SidebarProblemStatus problem={correctProblem} contestState={teamStatus.contestState} />
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="problemSectionExplanation">Solved problems appear here</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="problemResultsSection">
|
||||||
|
<div>
|
||||||
|
<span class="problemResultsSectionHeader incorrect">Incorrect </span>
|
||||||
|
<span class="problemResultsSectionCount">{teamStatus.incorrectProblems.length} of {totalProblems}</span>
|
||||||
|
</div>
|
||||||
|
{#if teamStatus.incorrectProblems.length > 0}
|
||||||
|
{#each teamStatus.incorrectProblems as incorrectProblem (JSON.stringify(incorrectProblem))}
|
||||||
|
<SidebarProblemStatus problem={incorrectProblem} contestState={teamStatus.contestState} />
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="problemSectionExplanation">
|
||||||
|
Attempted problems appear here until solved
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if teamStatus.notStartedProblems.length > 0}
|
||||||
|
<div class="problemResultsSection">
|
||||||
|
<div>
|
||||||
|
<span class="problemResultsSectionHeader notAttempted">Not Attempted </span>
|
||||||
|
<span class="problemResultsSectionCount">{teamStatus.notStartedProblems.length} of {totalProblems}</span>
|
||||||
|
</div>
|
||||||
|
{#each teamStatus.notStartedProblems as notStartedProblem (JSON.stringify(notStartedProblem))}
|
||||||
|
<SidebarProblemStatus problem={notStartedProblem} contestState={teamStatus.contestState} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span>Fetching data...</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sidebarSectionHeader {
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid var(--vscode-charts-yellow);
|
||||||
|
color: var(--vscode-charts-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarSection {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-left: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonContainer {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarButton {
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 80%;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoLabel {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoData {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extraInfo {
|
||||||
|
font-size: smaller;
|
||||||
|
visibility: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemResultsSection {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemResultsSectionHeader {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemSectionExplanation {
|
||||||
|
margin-left: 18px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemResultsSectionHeader.correct {
|
||||||
|
color: var(--vscode-charts-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemResultsSectionHeader.inProgress {
|
||||||
|
color: var(--vscode-editorLightBulb-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemResultsSectionHeader.incorrect {
|
||||||
|
color: var(--vscode-charts-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemResultsSectionCount {
|
||||||
|
padding-left: 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -0,0 +1,220 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export const watSubmissionsImageUrl =
|
||||||
|
new URL('../../media/SubmissionIcons/TeamPanel/none.png', import.meta.url).href;
|
||||||
|
|
||||||
|
export const correctSubmissionImageUrl =
|
||||||
|
new URL('../../media/SubmissionIcons/TeamPanel/correct.png', import.meta.url).href;
|
||||||
|
|
||||||
|
export const incorrectSubmissionImageUrl =
|
||||||
|
new URL('../../media/SubmissionIcons/TeamPanel/incorrect.png', import.meta.url).href;
|
||||||
|
|
||||||
|
export const pendingSubmissionImageUrl =
|
||||||
|
new URL('../../media/SubmissionIcons/TeamPanel/unknown.png', import.meta.url).href;
|
||||||
|
|
||||||
|
export const noSubmissionsImageUrl =
|
||||||
|
new URL('../../media/SubmissionIcons/TeamPanel/none.png', import.meta.url).href;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { SidebarProblemWithSubmissions } from '../../src/SidebarProvider';
|
||||||
|
import type { ContestStateForExtension, SubmissionForExtension, SubmissionStateForExtension } from '../../src/contestMonitor/contestMonitorSharedTypes';
|
||||||
|
|
||||||
|
export let contestState: ContestStateForExtension;
|
||||||
|
export let problem: SidebarProblemWithSubmissions;
|
||||||
|
|
||||||
|
const sortedSubmissions = problem.submissions
|
||||||
|
? problem.submissions.sort((s1, s2) => Date.parse(s1.createdAt.toString()) - Date.parse(s2.createdAt.toString()))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const highlightClasses = `${(problem.modified ? "highlight" : "")} ${problem.overallState?.toLowerCase()}`;
|
||||||
|
|
||||||
|
function getStatusImageUrl(overallState: SubmissionStateForExtension | null): string {
|
||||||
|
switch (overallState) {
|
||||||
|
case 'Correct':
|
||||||
|
return correctSubmissionImageUrl;
|
||||||
|
case 'Incorrect':
|
||||||
|
return incorrectSubmissionImageUrl;
|
||||||
|
case 'Processing':
|
||||||
|
return pendingSubmissionImageUrl;
|
||||||
|
default:
|
||||||
|
return watSubmissionsImageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContestOffsetDisplay(submission: SubmissionForExtension): string {
|
||||||
|
if (!contestState.startTime) {
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contestStartAbsoluteMillis = Date.parse(contestState.startTime.toString());
|
||||||
|
const submissionTimeAbsoluteMillis = Date.parse(submission.createdAt.toString());
|
||||||
|
const submissionRelativeMillis = submissionTimeAbsoluteMillis - contestStartAbsoluteMillis;
|
||||||
|
const minutesFromContestStart = Math.ceil(submissionRelativeMillis / 1000 / 60);
|
||||||
|
return `${minutesFromContestStart} min`;
|
||||||
|
} catch (error) {
|
||||||
|
return '???';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralize(num: number, singular: string, plural: string) {
|
||||||
|
return num === 1 ? singular : plural;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={"problemStatusDiv " + highlightClasses}>
|
||||||
|
<div class={"problemHeaderDiv"}>
|
||||||
|
<img
|
||||||
|
class="overallStatusImage"
|
||||||
|
src={getStatusImageUrl(problem.overallState)}
|
||||||
|
alt={problem.overallState}
|
||||||
|
/>
|
||||||
|
<div class="problemHeader">
|
||||||
|
{#if problem.submissions.length == 0}
|
||||||
|
<span class="problemHeaderName">{problem.problem.friendlyName}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="problemHeaderName">{problem.problem.friendlyName}</span>
|
||||||
|
<span class="problemHeaderSubmitCount">
|
||||||
|
{problem.submissions.length}
|
||||||
|
{pluralize(problem.submissions.length, 'attempt', 'attempts')}</span>
|
||||||
|
{#if problem.submissions.filter((s) => s.state === 'Processing').length > 0}
|
||||||
|
<span>({problem.submissions.filter((s) => s.state === 'Processing').length} pending...)</span>
|
||||||
|
{/if}
|
||||||
|
{#if problem.overallState === "Correct"}
|
||||||
|
<span class="individualSubmissionAttemptTime"> @ {getContestOffsetDisplay(problem.submissions.filter(s => s.state === "Correct")[0])}</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if problem.overallState !== "Correct"}
|
||||||
|
{#each sortedSubmissions as submission, i}
|
||||||
|
<div class="individualSubmissionDiv">
|
||||||
|
<span class="individualSubmissionAttemptNumber">Submit #{i + 1}: </span>
|
||||||
|
<img
|
||||||
|
class="individualSubmissionStatusImage"
|
||||||
|
src={getStatusImageUrl(submission.state)}
|
||||||
|
alt={submission.state}/>
|
||||||
|
<span class="individualSubmissionResult {submission.state.toLowerCase()}">
|
||||||
|
{submission.state}
|
||||||
|
</span>
|
||||||
|
<span class="individualSubmissionAttemptTime"> @ {getContestOffsetDisplay(submission)}</span>
|
||||||
|
</div>
|
||||||
|
{#if submission.message}
|
||||||
|
<div class="individualSubmissionMessageWrapper">
|
||||||
|
Judge: <span class="individualSubmissionMessage">{submission.message}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.problemStatusDiv {
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemHeaderDiv {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemHeaderName {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemHeaderSubmitCount {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problemHeader {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overallStatusImage {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.individualSubmissionStatusImage {
|
||||||
|
height: 12px;
|
||||||
|
width: 12px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.individualSubmissionDiv {
|
||||||
|
margin-left: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.individualSubmissionAttemptNumber {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.individualSubmissionAttemptTime {
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.individualSubmissionResult {
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.individualSubmissionResult.processing {
|
||||||
|
color: var(--vscode-charts-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.individualSubmissionResult.correct {
|
||||||
|
color: var(--vscode-charts-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.individualSubmissionResult.incorrect {
|
||||||
|
color: var(--vscode-charts-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.individualSubmissionMessageWrapper {
|
||||||
|
padding-left: 64px;
|
||||||
|
padding-top: 2px;
|
||||||
|
color: var(--vscode-charts-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.individualSubmissionMessage {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight.correct {
|
||||||
|
animation: highlightAnimationCorrect 2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight.incorrect {
|
||||||
|
animation: highlightAnimationIncorrect 2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight.processing {
|
||||||
|
animation: highlightAnimationProcessing 2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes highlightAnimationCorrect {
|
||||||
|
from {
|
||||||
|
background-color: var(--vscode-charts-green);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes highlightAnimationIncorrect {
|
||||||
|
from {
|
||||||
|
background-color: var(--vscode-charts-red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes highlightAnimationProcessing {
|
||||||
|
from {
|
||||||
|
background-color: var(--vscode-charts-yellow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
29
web/src/lib/contestMonitor/contestMonitorSharedTypes.ts
Normal file
29
web/src/lib/contestMonitor/contestMonitorSharedTypes.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
export type FullStateForExtension = {
|
||||||
|
contestState: ContestStateForExtension;
|
||||||
|
submissions: SubmissionForExtension[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProblemNameForExtension = {
|
||||||
|
id: number;
|
||||||
|
friendlyName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContestStateForExtension = {
|
||||||
|
startTime: Date | null;
|
||||||
|
endTime: Date | null;
|
||||||
|
problems: ProblemNameForExtension[];
|
||||||
|
isActive: boolean;
|
||||||
|
isScoreboardFrozen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmissionStateForExtension = 'Processing' | 'Correct' | 'Incorrect';
|
||||||
|
|
||||||
|
export type SubmissionForExtension = {
|
||||||
|
id: number;
|
||||||
|
contestId: number;
|
||||||
|
teamId: number;
|
||||||
|
problemId: number;
|
||||||
|
createdAt: Date;
|
||||||
|
state: SubmissionStateForExtension;
|
||||||
|
message: string | null;
|
||||||
|
};
|
15
web/src/lib/contestMonitor/contestMonitorUtils.ts
Normal file
15
web/src/lib/contestMonitor/contestMonitorUtils.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import type { SubmissionState } from '@prisma/client';
|
||||||
|
import type { SubmissionStateForExtension } from './contestMonitorSharedTypes';
|
||||||
|
|
||||||
|
export function convertSubmissionStateForExtension(
|
||||||
|
state: SubmissionState
|
||||||
|
): SubmissionStateForExtension {
|
||||||
|
switch (state) {
|
||||||
|
case 'Correct':
|
||||||
|
return 'Correct';
|
||||||
|
case 'Incorrect':
|
||||||
|
return 'Incorrect';
|
||||||
|
default:
|
||||||
|
return 'Processing';
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,7 @@ import { Language, SubmissionState } from '@prisma/client';
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { genPassword } from '../../teams/util';
|
import { genPassword } from '../../teams/util';
|
||||||
|
import { createRepos } from '$lib/server/repos';
|
||||||
|
|
||||||
export const load = (async () => {}) satisfies PageServerLoad;
|
export const load = (async () => {}) satisfies PageServerLoad;
|
||||||
|
|
||||||
@ -40,6 +41,7 @@ export const actions = {
|
|||||||
default: async ({ request }) => {
|
default: async ({ request }) => {
|
||||||
let parsedContest: ContestImportData;
|
let parsedContest: ContestImportData;
|
||||||
let includeSubmissions: boolean;
|
let includeSubmissions: boolean;
|
||||||
|
let createReposAndKeepContestRunning: boolean;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
@ -50,6 +52,8 @@ export const actions = {
|
|||||||
|
|
||||||
parsedContest = JSON.parse(contestJson);
|
parsedContest = JSON.parse(contestJson);
|
||||||
includeSubmissions = formData.get('includeSubmissions')?.toString() == 'on';
|
includeSubmissions = formData.get('includeSubmissions')?.toString() == 'on';
|
||||||
|
createReposAndKeepContestRunning =
|
||||||
|
formData.get('createReposAndKeepContestRunning')?.toString() == 'on';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(400, { message: 'Could not parse contest data: ' + err?.toString() });
|
return fail(400, { message: 'Could not parse contest data: ' + err?.toString() });
|
||||||
}
|
}
|
||||||
@ -69,7 +73,7 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Single transaction
|
// Single transaction
|
||||||
await db.contest.create({
|
const contest = await db.contest.create({
|
||||||
data: {
|
data: {
|
||||||
name: parsedContest.Name,
|
name: parsedContest.Name,
|
||||||
startTime: contestStart,
|
startTime: contestStart,
|
||||||
@ -128,6 +132,30 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (createReposAndKeepContestRunning) {
|
||||||
|
const fullContest = await db.contest.findUnique({
|
||||||
|
where: { id: contest.id },
|
||||||
|
include: { teams: { include: { activeTeam: true } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fullContest && parsedContest.Problems.length > 0 && parsedContest.Teams.length > 0) {
|
||||||
|
if (!fullContest.startTime) {
|
||||||
|
await db.contest.update({
|
||||||
|
where: { id: fullContest.id },
|
||||||
|
data: {
|
||||||
|
startTime: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fullContest.teams.forEach(async (team) => {
|
||||||
|
await db.activeTeam.create({ data: { teamId: team.id, contestId: contest.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await createRepos(contest.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(400, { message: 'Error updating database: ' + err?.toString() });
|
return fail(400, { message: 'Error updating database: ' + err?.toString() });
|
||||||
}
|
}
|
||||||
|
@ -70,7 +70,16 @@
|
|||||||
<span>{numProblems ?? 'No'} Problems</span><br />
|
<span>{numProblems ?? 'No'} Problems</span><br />
|
||||||
<span>{numSubmissions ?? 'No'} Submissions</span>
|
<span>{numSubmissions ?? 'No'} Submissions</span>
|
||||||
(<input type="checkbox" checked name="includeSubmissions" id="includeSubmissions" />
|
(<input type="checkbox" checked name="includeSubmissions" id="includeSubmissions" />
|
||||||
<label id="includeSubmissionsLabel" for="includeSubmissions">Include</label>)
|
<label id="includeSubmissionsLabel" for="includeSubmissions">Include</label>)<br />
|
||||||
|
|
||||||
|
<label id="createReposAndKeepContestRunningLabel" for="createReposAndKeepContestRunning"
|
||||||
|
>Create Repos & Activate Contest?
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="createReposAndKeepContestRunning"
|
||||||
|
id="createReposAndKeepContestRunning"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex flex-row justify-content-end gap-2 m-2">
|
<div class="d-flex flex-row justify-content-end gap-2 m-2">
|
||||||
|
@ -126,7 +126,8 @@ export const POST = (async ({ request }) => {
|
|||||||
state: SubmissionState.Incorrect,
|
state: SubmissionState.Incorrect,
|
||||||
gradedAt: new Date(),
|
gradedAt: new Date(),
|
||||||
stateReason: SubmissionStateReason.BuildError,
|
stateReason: SubmissionStateReason.BuildError,
|
||||||
stateReasonDetails: data.data.result.resultKindReason
|
stateReasonDetails: data.data.result.resultKindReason,
|
||||||
|
message: 'Compilation Failed'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return json({ success: true });
|
return json({ success: true });
|
||||||
|
@ -6,7 +6,10 @@ export const GET = (async ({ params }) => {
|
|||||||
const session = params.session;
|
const session = params.session;
|
||||||
const activeTeam = await db.activeTeam.findUnique({
|
const activeTeam = await db.activeTeam.findUnique({
|
||||||
where: { sessionToken: session },
|
where: { sessionToken: session },
|
||||||
include: { team: { select: { language: true } } }
|
include: {
|
||||||
|
team: { select: { language: true, name: true } },
|
||||||
|
contest: { select: { name: true } }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (activeTeam === null) {
|
if (activeTeam === null) {
|
||||||
return json({ success: false });
|
return json({ success: false });
|
||||||
@ -14,7 +17,9 @@ export const GET = (async ({ params }) => {
|
|||||||
return json({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
|
teamName: activeTeam.team.name,
|
||||||
teamId: activeTeam.teamId,
|
teamId: activeTeam.teamId,
|
||||||
|
contestName: activeTeam.contest.name,
|
||||||
contestId: activeTeam.contestId,
|
contestId: activeTeam.contestId,
|
||||||
language: activeTeam.team.language
|
language: activeTeam.team.language
|
||||||
}
|
}
|
||||||
|
56
web/src/routes/api/team/[session]/contestState/+server.ts
Normal file
56
web/src/routes/api/team/[session]/contestState/+server.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { db } from '$lib/server/prisma';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import type {
|
||||||
|
ContestStateForExtension,
|
||||||
|
FullStateForExtension,
|
||||||
|
SubmissionForExtension
|
||||||
|
} from '$lib/contestMonitor/contestMonitorSharedTypes';
|
||||||
|
import { convertSubmissionStateForExtension } from '$lib/contestMonitor/contestMonitorUtils';
|
||||||
|
|
||||||
|
export const GET = (async ({ params }) => {
|
||||||
|
const sessionToken = params.session;
|
||||||
|
if (!sessionToken) {
|
||||||
|
error(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTeam = await db.activeTeam.findUnique({
|
||||||
|
where: { sessionToken: sessionToken },
|
||||||
|
include: {
|
||||||
|
contest: { include: { problems: { select: { id: true, friendlyName: true } } } },
|
||||||
|
team: { include: { submissions: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!activeTeam) {
|
||||||
|
// Team "logged in", but the token doesn't match
|
||||||
|
// Maybe they're still logged into a previous contest?
|
||||||
|
error(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissions: SubmissionForExtension[] = activeTeam.team.submissions
|
||||||
|
.filter((s) => s.contestId == activeTeam.contestId)
|
||||||
|
.map<SubmissionForExtension>((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
contestId: s.contestId,
|
||||||
|
teamId: s.teamId,
|
||||||
|
problemId: s.problemId,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
state: convertSubmissionStateForExtension(s.state),
|
||||||
|
message: s.message
|
||||||
|
}));
|
||||||
|
|
||||||
|
const contestState: ContestStateForExtension = {
|
||||||
|
startTime: activeTeam.contest.startTime,
|
||||||
|
endTime: null,
|
||||||
|
problems: activeTeam.contest.problems,
|
||||||
|
isActive: true, // todo
|
||||||
|
isScoreboardFrozen: false // todo
|
||||||
|
};
|
||||||
|
|
||||||
|
const fullState: FullStateForExtension = {
|
||||||
|
submissions: submissions,
|
||||||
|
contestState: contestState
|
||||||
|
};
|
||||||
|
|
||||||
|
return json({ success: true, data: fullState });
|
||||||
|
}) satisfies RequestHandler;
|
@ -3,6 +3,8 @@ import { error, json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { SubmissionState } from '@prisma/client';
|
import { SubmissionState } from '@prisma/client';
|
||||||
|
import type { SubmissionForExtension } from '$lib/contestMonitor/contestMonitorSharedTypes';
|
||||||
|
import { convertSubmissionStateForExtension } from '$lib/contestMonitor/contestMonitorUtils';
|
||||||
|
|
||||||
const submitPostData = z.object({
|
const submitPostData = z.object({
|
||||||
commitHash: z.string(),
|
commitHash: z.string(),
|
||||||
@ -47,7 +49,7 @@ export const POST = (async ({ params, request }) => {
|
|||||||
return json({ success: false, message: 'Already submitted correct submission' });
|
return json({ success: false, message: 'Already submitted correct submission' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.submission.create({
|
const submission = await db.submission.create({
|
||||||
data: {
|
data: {
|
||||||
state: SubmissionState.Queued,
|
state: SubmissionState.Queued,
|
||||||
commitHash: data.data.commitHash,
|
commitHash: data.data.commitHash,
|
||||||
@ -57,5 +59,15 @@ export const POST = (async ({ params, request }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return json({ success: true });
|
const submissionForExtension: SubmissionForExtension = {
|
||||||
|
id: submission.id,
|
||||||
|
contestId: submission.contestId,
|
||||||
|
teamId: submission.teamId,
|
||||||
|
problemId: submission.problemId,
|
||||||
|
createdAt: submission.createdAt,
|
||||||
|
state: convertSubmissionStateForExtension(submission.state),
|
||||||
|
message: submission.message
|
||||||
|
};
|
||||||
|
|
||||||
|
return json({ success: true, submission: submissionForExtension });
|
||||||
}) satisfies RequestHandler;
|
}) satisfies RequestHandler;
|
||||||
|
@ -20,12 +20,23 @@ export const POST = (async ({ request }) => {
|
|||||||
where: { name: data.data.teamname },
|
where: { name: data.data.teamname },
|
||||||
include: { activeTeam: true }
|
include: { activeTeam: true }
|
||||||
});
|
});
|
||||||
if (!team || !team.activeTeam || team.password !== data.data.password) {
|
|
||||||
return json({ success: false, message: 'Invalid login' });
|
if (!team) {
|
||||||
|
return json({ success: false, message: 'Invalid team' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (team.password !== data.data.password) {
|
||||||
|
return json({ success: false, message: 'Invalid password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!team.activeTeam) {
|
||||||
|
return json({ success: false, message: 'No active contest for team' });
|
||||||
|
}
|
||||||
|
|
||||||
const activeTeam = await db.activeTeam.update({
|
const activeTeam = await db.activeTeam.update({
|
||||||
where: { id: team.activeTeam.id },
|
where: { id: team.activeTeam.id },
|
||||||
data: { sessionToken: UUID.v4(), sessionCreatedAt: new Date() }
|
data: { sessionToken: UUID.v4(), sessionCreatedAt: new Date() }
|
||||||
});
|
});
|
||||||
|
|
||||||
return json({ success: true, token: activeTeam.sessionToken });
|
return json({ success: true, token: activeTeam.sessionToken });
|
||||||
}) satisfies RequestHandler;
|
}) satisfies RequestHandler;
|
||||||
|
@ -5,7 +5,8 @@ import { db } from '$lib/server/prisma';
|
|||||||
|
|
||||||
const logoutPostData = z
|
const logoutPostData = z
|
||||||
.object({
|
.object({
|
||||||
token: z.string()
|
token: z.string(),
|
||||||
|
teamId: z.number()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@ -14,9 +15,37 @@ export const POST = (async ({ request }) => {
|
|||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
error(400);
|
error(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeTeam = await db.activeTeam.findUnique({
|
||||||
|
where: { teamId: data.data.teamId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activeTeam) {
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
message: 'No active team found with the provided teamId. Client should log out.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTeam.sessionToken !== data.data.token) {
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
message: 'Active team found, but provided sessionToken is incorrect. Client should log out.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await db.activeTeam.update({
|
await db.activeTeam.update({
|
||||||
where: { sessionToken: data.data.token },
|
where: { sessionToken: data.data.token },
|
||||||
data: { sessionToken: null, sessionCreatedAt: null }
|
data: { sessionToken: null, sessionCreatedAt: null }
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"Active team found with correct sessionToken, but couldn't clear it out. Client should still log out."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return json({ success: true });
|
return json({ success: true });
|
||||||
}) satisfies RequestHandler;
|
}) satisfies RequestHandler;
|
||||||
|
Loading…
Reference in New Issue
Block a user