From 4a3ff56e7a2aeb1bb2d1390249fe83118a992d72 Mon Sep 17 00:00:00 2001 From: David Poeschl Date: Tue, 5 Mar 2024 14:50:16 -0800 Subject: [PATCH] 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' --- .../SubmissionIcons/TeamPanel/correct.png | Bin 0 -> 11382 bytes .../SubmissionIcons/TeamPanel/incorrect.png | Bin 0 -> 8412 bytes .../media/SubmissionIcons/TeamPanel/none.png | Bin 0 -> 6793 bytes .../SubmissionIcons/TeamPanel/unknown.png | Bin 0 -> 3951 bytes extension/bwcontest/package.json | 14 +- extension/bwcontest/src/SidebarProvider.ts | 180 ++++++++++++-- .../contestMonitorSharedTypes.ts | 29 +++ .../contestMonitor/contestStateSyncManager.ts | 170 ++++++++++++++ .../src/contestMonitor/pollingService.ts | 66 ++++++ extension/bwcontest/src/extension.ts | 38 ++- extension/bwcontest/src/outputPanelLog.ts | 7 + extension/bwcontest/src/problemPanel.ts | 24 +- extension/bwcontest/src/sharedTypes.ts | 9 + extension/bwcontest/src/submit.ts | 36 ++- .../bwcontest/src/utilities/LiteEvent.ts | 30 +++ .../src/utilities/SimpleCancellationToken.ts | 8 + extension/bwcontest/src/utilities/sleep.ts | 3 + .../webviews/components/ProblemPanel.svelte | 8 +- .../webviews/components/Sidebar.svelte | 203 ++++++++++++++-- .../components/SidebarProblemStatus.svelte | 220 ++++++++++++++++++ .../contestMonitorSharedTypes.ts | 29 +++ .../lib/contestMonitor/contestMonitorUtils.ts | 15 ++ .../admin/contests/import/+page.server.ts | 30 ++- .../routes/admin/contests/import/+page.svelte | 11 +- web/src/routes/api/submission/+server.ts | 3 +- web/src/routes/api/team/[session]/+server.ts | 7 +- .../team/[session]/contestState/+server.ts | 56 +++++ .../api/team/[session]/submit/+server.ts | 16 +- web/src/routes/api/team/login/+server.ts | 15 +- web/src/routes/api/team/logout/+server.ts | 37 ++- 30 files changed, 1189 insertions(+), 75 deletions(-) create mode 100644 extension/bwcontest/media/SubmissionIcons/TeamPanel/correct.png create mode 100644 extension/bwcontest/media/SubmissionIcons/TeamPanel/incorrect.png create mode 100644 extension/bwcontest/media/SubmissionIcons/TeamPanel/none.png create mode 100644 extension/bwcontest/media/SubmissionIcons/TeamPanel/unknown.png create mode 100644 extension/bwcontest/src/contestMonitor/contestMonitorSharedTypes.ts create mode 100644 extension/bwcontest/src/contestMonitor/contestStateSyncManager.ts create mode 100644 extension/bwcontest/src/contestMonitor/pollingService.ts create mode 100644 extension/bwcontest/src/outputPanelLog.ts create mode 100644 extension/bwcontest/src/sharedTypes.ts create mode 100644 extension/bwcontest/src/utilities/LiteEvent.ts create mode 100644 extension/bwcontest/src/utilities/SimpleCancellationToken.ts create mode 100644 extension/bwcontest/src/utilities/sleep.ts create mode 100644 extension/bwcontest/webviews/components/SidebarProblemStatus.svelte create mode 100644 web/src/lib/contestMonitor/contestMonitorSharedTypes.ts create mode 100644 web/src/lib/contestMonitor/contestMonitorUtils.ts create mode 100644 web/src/routes/api/team/[session]/contestState/+server.ts diff --git a/extension/bwcontest/media/SubmissionIcons/TeamPanel/correct.png b/extension/bwcontest/media/SubmissionIcons/TeamPanel/correct.png new file mode 100644 index 0000000000000000000000000000000000000000..0ab8f4a193ebd36bee5c5e7f5893a2fd24401d38 GIT binary patch literal 11382 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 literal 0 HcmV?d00001 diff --git a/extension/bwcontest/media/SubmissionIcons/TeamPanel/incorrect.png b/extension/bwcontest/media/SubmissionIcons/TeamPanel/incorrect.png new file mode 100644 index 0000000000000000000000000000000000000000..705135d754a67c7cd7fda7bdd0a60174390ab2a9 GIT binary patch literal 8412 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�QTC(2PaQ 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 bpZ{g_9#XktUx8zukFc~g^wk@H4l(}+s_j6j literal 0 HcmV?d00001 diff --git a/extension/bwcontest/media/SubmissionIcons/TeamPanel/unknown.png b/extension/bwcontest/media/SubmissionIcons/TeamPanel/unknown.png new file mode 100644 index 0000000000000000000000000000000000000000..71b74225883b13296516c1e5d6f048ebbb8c4882 GIT binary patch literal 3951 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 JPDHLkV1g!`i_QQ5 literal 0 HcmV?d00001 diff --git a/extension/bwcontest/package.json b/extension/bwcontest/package.json index 9d703c7..a7ad95f 100644 --- a/extension/bwcontest/package.json +++ b/extension/bwcontest/package.json @@ -9,7 +9,7 @@ "categories": [ "Other" ], - "activationEvents": [], + "activationEvents": ["onStartupFinished"], "main": "./out/main.js", "contributes": { "configuration": { @@ -34,6 +34,11 @@ "type": "string", "default": "", "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": { "vscode:prepublish": "npm run compile", diff --git a/extension/bwcontest/src/SidebarProvider.ts b/extension/bwcontest/src/SidebarProvider.ts index a161c11..118dffb 100644 --- a/extension/bwcontest/src/SidebarProvider.ts +++ b/extension/bwcontest/src/SidebarProvider.ts @@ -3,30 +3,65 @@ import { getNonce } from './getNonce'; import { cloneAndOpenRepo } from './extension'; import { BWPanel } from './problemPanel'; 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 TeamData = { - teamId: number; - contestId: number; - language: ContestLanguage; -}; - -export type WebviewMessageType = { msg: 'onLogin'; data: TeamData } | { msg: 'onLogout' }; +export type WebviewMessageType = + { msg: 'onLogin'; data: TeamData } | + { msg: 'onLogout' } | + { msg: 'teamStatusUpdated'; data: SidebarTeamStatus | null }; export type MessageType = | { msg: 'onTestAndSubmit' } - | { msg: 'onStartup' } + | { msg: 'onUIMount' } | { msg: 'onClone'; data: { contestId: number; teamId: number } } | { msg: 'onLogin'; data: { teamName: string; password: string } } | { 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 { + private webview: vscode.Webview | null = null; + constructor( private readonly extensionUri: vscode.Uri, private readonly context: vscode.ExtensionContext, 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( teamName: string, @@ -42,47 +77,135 @@ export class SidebarProvider implements vscode.WebviewViewProvider { }) }); 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 ?? "")); vscode.window.showErrorMessage('BWContest: Invalid Login'); return; } + const sessionToken = resData.token; - this.context.globalState.update('token', sessionToken); const teamRes = await fetch(urlJoin(this.webUrl, `api/team/${sessionToken}`), { method: 'GET' }); const data2 = await teamRes.json(); if (!data2.success) { + outputPanelLog.error('Login attempt retrieved token but not team details. Staying logged out.'); + vscode.window.showErrorMessage('BWContest: Invalid Login'); return; } + + this.context.globalState.update('token', sessionToken); this.context.globalState.update('teamData', data2.data); + + startTeamStatusPolling(); + + outputPanelLog.info('Login succeeded'); 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) { const sessionToken = this.context.globalState.get('token'); 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'); + 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'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + teamId: teamData.teamId, token: sessionToken }) }); + 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; } - const data2 = await res.json(); - if (data2.success === true) { - webviewPostMessage({ msg: 'onLogout' }); - this.context.globalState.update('token', undefined); + + outputPanelLog.info(`Team requested logout, completed successfully. ` + responseMessage); + this.clearLocalTeamDataAndFinishLogout(webviewPostMessage); + } + + 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) { + 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(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) { + outputPanelLog.trace("SidebarProvider resolveWebviewView"); const webview = webviewView.webview; + this.webview = webview; webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] @@ -101,7 +224,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } break; } - case 'onStartup': { + case 'onUIMount': { + outputPanelLog.trace("SidebarProvider onUIMount"); const token = this.context.globalState.get('token'); const teamData = this.context.globalState.get('teamData'); if (token !== undefined && teamData !== undefined) { @@ -109,6 +233,10 @@ export class SidebarProvider implements vscode.WebviewViewProvider { msg: 'onLogin', data: teamData }); + + const currentSubmissionsList = getCachedContestTeamState(); + outputPanelLog.trace("onUIMount, currentSubmissionsList is " + JSON.stringify(currentSubmissionsList)); + this.updateTeamStatus(currentSubmissionsList); } break; } @@ -169,3 +297,17 @@ export class SidebarProvider implements vscode.WebviewViewProvider { `; } } +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; + } +} \ No newline at end of file diff --git a/extension/bwcontest/src/contestMonitor/contestMonitorSharedTypes.ts b/extension/bwcontest/src/contestMonitor/contestMonitorSharedTypes.ts new file mode 100644 index 0000000..4ec0829 --- /dev/null +++ b/extension/bwcontest/src/contestMonitor/contestMonitorSharedTypes.ts @@ -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 +} \ No newline at end of file diff --git a/extension/bwcontest/src/contestMonitor/contestStateSyncManager.ts b/extension/bwcontest/src/contestMonitor/contestStateSyncManager.ts new file mode 100644 index 0000000..ce9446f --- /dev/null +++ b/extension/bwcontest/src/contestMonitor/contestStateSyncManager.ts @@ -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 +} + +export type SubmissionListStateChangedEventArgs = { + contestTeamState: ContestTeamState, + changedProblemIds: Set +} + +let latestContestTeamState: ContestTeamState | null = null; + +export function getCachedContestTeamState(): ContestTeamState | null { + return latestContestTeamState; +} + +export function clearCachedContestTeamState(): void { + latestContestTeamState = null; +} + +const onSubmissionsListChanged = new LiteEvent(); +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(); + + 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 { + const orderedSubmissionsByProblemId = new Map(); + 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([submission.problemId]), + }); +} \ No newline at end of file diff --git a/extension/bwcontest/src/contestMonitor/pollingService.ts b/extension/bwcontest/src/contestMonitor/pollingService.ts new file mode 100644 index 0000000..2c122d4 --- /dev/null +++ b/extension/bwcontest/src/contestMonitor/pollingService.ts @@ -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 ?? "")); + } + + 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.`); +} \ No newline at end of file diff --git a/extension/bwcontest/src/extension.ts b/extension/bwcontest/src/extension.ts index 4361d2c..bb62412 100644 --- a/extension/bwcontest/src/extension.ts +++ b/extension/bwcontest/src/extension.ts @@ -5,12 +5,15 @@ import urlJoin from 'url-join'; import git from 'isomorphic-git'; import path = require('path'); import http from 'isomorphic-git/http/node'; +import outputPanelLog from './outputPanelLog'; +import { startTeamStatusPollingOnActivation, stopTeamStatusPolling, useFastPolling } from './contestMonitor/pollingService'; export interface BWContestSettings { repoBaseUrl: string; webUrl: string; repoClonePath: string; javaPath: string; + debugFastPolling: boolean; } 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); - await git.clone({ fs, http, dir, url: repoUrl }); + outputPanelLog.info(`Running 'git clone' to directory: ${dir}`); + try { + 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(); const addedFolder = vscode.workspace.updateWorkspaceFolders( @@ -96,14 +107,35 @@ export async function cloneAndOpenRepo(contestId: number, teamId: number) { } export function activate(context: vscode.ExtensionContext) { + outputPanelLog.info("BWContest Extension Activated"); + const sidebarProvider = new SidebarProvider( context.extensionUri, context, extensionSettings().webUrl ); + + let fastPolling = extensionSettings().debugFastPolling; + useFastPolling(fastPolling); + 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() {} diff --git a/extension/bwcontest/src/outputPanelLog.ts b/extension/bwcontest/src/outputPanelLog.ts new file mode 100644 index 0000000..853904b --- /dev/null +++ b/extension/bwcontest/src/outputPanelLog.ts @@ -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; \ No newline at end of file diff --git a/extension/bwcontest/src/problemPanel.ts b/extension/bwcontest/src/problemPanel.ts index c6a0225..29f95d2 100644 --- a/extension/bwcontest/src/problemPanel.ts +++ b/extension/bwcontest/src/problemPanel.ts @@ -4,10 +4,12 @@ import urlJoin from 'url-join'; import { extensionSettings } from './extension'; import { runJava } from './run/java'; import { join } from 'path'; -import { TeamData } from './SidebarProvider'; import { submitProblem } from './submit'; import { runCSharp } from './run/csharp'; import { runCpp } from './run/cpp'; +import { TeamData } from './sharedTypes'; +import outputPanelLog from './outputPanelLog'; +import { recordInitialSubmission } from './contestMonitor/contestStateSyncManager'; export type ProblemData = { id: number; @@ -54,6 +56,7 @@ export class BWPanel { } public static show(context: vscode.ExtensionContext, webUrl: string) { + outputPanelLog.info("Showing BWPanel"); const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined; @@ -117,20 +120,27 @@ export class BWPanel { } await vscode.workspace.saveAll(); 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', 'No' ); if (ans !== 'Yes') { return; } - submitProblem(sessionToken, teamData.contestId, teamData.teamId, problemId).then((result) => { - if (result.success === true) { - vscode.window.showInformationMessage('Submitted!'); + + try { + const submissionResult = await submitProblem(sessionToken, teamData.contestId, teamData.teamId, problemId); + if (submissionResult.success === true) { + recordInitialSubmission(submissionResult.submission); + vscode.window.showInformationMessage(`Submitted '${problem.name}'!`); } 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) { diff --git a/extension/bwcontest/src/sharedTypes.ts b/extension/bwcontest/src/sharedTypes.ts new file mode 100644 index 0000000..8c44cc3 --- /dev/null +++ b/extension/bwcontest/src/sharedTypes.ts @@ -0,0 +1,9 @@ +export type ContestLanguage = 'Java' | 'CSharp' | 'CPP'; + +export type TeamData = { + teamId: number; + teamName: string; + contestId: number; + contestName: string; + language: ContestLanguage; +}; \ No newline at end of file diff --git a/extension/bwcontest/src/submit.ts b/extension/bwcontest/src/submit.ts index d87cd49..779f181 100644 --- a/extension/bwcontest/src/submit.ts +++ b/extension/bwcontest/src/submit.ts @@ -4,24 +4,37 @@ import git from 'isomorphic-git'; import path = require('path'); import http from 'isomorphic-git/http/node'; import urlJoin from 'url-join'; +import outputPanelLog from './outputPanelLog'; +import { SubmissionForExtension } from './contestMonitor/contestMonitorSharedTypes'; export async function submitProblem( sessionToken: string, contestId: number, teamId: number, problemId: number -): Promise<{ success: true } | { success: false; message: string }> { - const repoClonePath = extensionSettings().repoClonePath; +): Promise<{ success: true; submission: SubmissionForExtension } | { success: false; message: string }> { + outputPanelLog.info(`Submitting problem id #{${problemId}}...`); - const repoDir = path.join(repoClonePath, 'BWContest', contestId.toString(), teamId.toString()); - await git.add({ fs, dir: repoDir, filepath: '.' }); + let hash: string; + let repoDir: string; - const hash = await git.commit({ - fs, - dir: repoDir, - author: { name: `Team ${teamId}` }, - message: `Submit problem ${problemId}` - }); + try { + const repoClonePath = extensionSettings().repoClonePath; + + repoDir = path.join(repoClonePath, 'BWContest', contestId.toString(), teamId.toString()); + await git.add({ fs, dir: repoDir, filepath: '.' }); + + hash = await git.commit({ + fs, + dir: repoDir, + author: { name: `Team ${teamId}` }, + message: `Submit problem ${problemId}` + }); + } + catch (error) { + outputPanelLog.error("Fail to make commit for submission: " + JSON.stringify(error)); + throw error; + } try { const result = await git.push({ @@ -54,5 +67,6 @@ export async function submitProblem( if (resData.success !== true) { return { success: false, message: resData.message }; } - return { success: true }; + + return { success: true, submission: resData.submission }; } diff --git a/extension/bwcontest/src/utilities/LiteEvent.ts b/extension/bwcontest/src/utilities/LiteEvent.ts new file mode 100644 index 0000000..2ef4c74 --- /dev/null +++ b/extension/bwcontest/src/utilities/LiteEvent.ts @@ -0,0 +1,30 @@ +// Modified from JasonKleban @ https://gist.github.com/JasonKleban/50cee44960c225ac1993c922563aa540 + +export { ILiteEvent, LiteEvent } + +interface ILiteEvent { + add(handler: { (data?: T): void }): void; + remove(handler: { (data?: T): void }): void; +} + +class LiteEvent implements ILiteEvent { + 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 { + return this; + } +} \ No newline at end of file diff --git a/extension/bwcontest/src/utilities/SimpleCancellationToken.ts b/extension/bwcontest/src/utilities/SimpleCancellationToken.ts new file mode 100644 index 0000000..4cf0130 --- /dev/null +++ b/extension/bwcontest/src/utilities/SimpleCancellationToken.ts @@ -0,0 +1,8 @@ +export class SimpleCancellationToken { + private _isCancelled: boolean = false; + get isCancelled() { return this._isCancelled; } + + cancel(): void { + this._isCancelled = true; + } +} \ No newline at end of file diff --git a/extension/bwcontest/src/utilities/sleep.ts b/extension/bwcontest/src/utilities/sleep.ts new file mode 100644 index 0000000..e9d53bf --- /dev/null +++ b/extension/bwcontest/src/utilities/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} \ No newline at end of file diff --git a/extension/bwcontest/webviews/components/ProblemPanel.svelte b/extension/bwcontest/webviews/components/ProblemPanel.svelte index 984e1bf..200ed0d 100644 --- a/extension/bwcontest/webviews/components/ProblemPanel.svelte +++ b/extension/bwcontest/webviews/components/ProblemPanel.svelte @@ -107,7 +107,7 @@

Sample Input (You can edit this!)

-