diff --git a/orchestrator/src/buttercup/orchestrator/ui/competition_api/main.py b/orchestrator/src/buttercup/orchestrator/ui/competition_api/main.py index 4122fd57..fc7bd7ea 100644 --- a/orchestrator/src/buttercup/orchestrator/ui/competition_api/main.py +++ b/orchestrator/src/buttercup/orchestrator/ui/competition_api/main.py @@ -768,6 +768,28 @@ def get_dashboard_task(task_id: str, database_manager: DatabaseManager = Depends return task_to_task_info(task) +@app.delete( + "/v1/dashboard/tasks/{task_id}", + response_model=Message, + tags=["dashboard"], +) +def cancel_dashboard_task( + task_id: str, + crs_client: CRSClient = Depends(get_crs_client), +) -> Message | Error: + """Cancel a task by forwarding a DELETE request to the task server.""" + logger.info(f"Dashboard: cancelling task {task_id}") + response = crs_client.cancel_task(task_id) + if response.success: + return Message( + message=f"Task {task_id} cancellation requested", + color="success", + ) + error_msg = response.get_user_friendly_error_message() + logger.error(f"Failed to cancel task {task_id}: {error_msg}") + return Error(message=f"Failed to cancel task: {error_msg}") + + @app.get("/v1/dashboard/tasks/{task_id}/crs-status", tags=["dashboard"]) def get_task_crs_status(task_id: str, database_manager: DatabaseManager = Depends(get_database_manager)) -> dict: """Get detailed CRS submission status and error information for a specific task""" diff --git a/orchestrator/src/buttercup/orchestrator/ui/competition_api/services/crs_client.py b/orchestrator/src/buttercup/orchestrator/ui/competition_api/services/crs_client.py index 0d042643..2d6d030b 100644 --- a/orchestrator/src/buttercup/orchestrator/ui/competition_api/services/crs_client.py +++ b/orchestrator/src/buttercup/orchestrator/ui/competition_api/services/crs_client.py @@ -229,6 +229,44 @@ def submit_task(self, task: Task) -> CRSResponse: logger.error(f"Error submitting task to CRS: {e}") return CRSResponse(success=False, status_code=0, response_text=str(e), error_details={"exception": str(e)}) + def cancel_task(self, task_id: str) -> CRSResponse: + """Cancel a task by sending a DELETE request to the CRS. + + Args: + task_id: UUID of the task to cancel + + Returns: + CRSResponse object with detailed status and error information + + """ + url = f"{self.crs_base_url}/v1/task/{task_id}/" + + auth = None + if self.username and self.password: + auth = (self.username, self.password) + + try: + logger.info(f"Cancelling task {task_id} via CRS at {url}") + + response = requests.delete( + url, + auth=auth, + timeout=30, + ) + + crs_response = CRSResponse.from_response(response) + crs_response.log_detailed_response(logger, f"Task cancellation for {task_id}") + return crs_response + + except Exception as e: + logger.error(f"Error cancelling task via CRS: {e}") + return CRSResponse( + success=False, + status_code=0, + response_text=str(e), + error_details={"exception": str(e)}, + ) + def submit_sarif_broadcast(self, broadcast: SARIFBroadcast) -> CRSResponse: """Submit a SARIF Broadcast to the CRS via POST /v1/sarif/ endpoint""" url = f"{self.crs_base_url}/v1/sarif/" diff --git a/orchestrator/src/buttercup/orchestrator/ui/static/index.html b/orchestrator/src/buttercup/orchestrator/ui/static/index.html index 4df4313f..75f786e0 100644 --- a/orchestrator/src/buttercup/orchestrator/ui/static/index.html +++ b/orchestrator/src/buttercup/orchestrator/ui/static/index.html @@ -12,6 +12,7 @@

Buttercup CRS Dashboard

@@ -66,6 +67,7 @@

Tasks

+ @@ -155,6 +157,8 @@

Submit New Task

placeholder="1800">
+ + @@ -176,6 +180,36 @@

Task Details

+ + +
diff --git a/orchestrator/src/buttercup/orchestrator/ui/static/script.js b/orchestrator/src/buttercup/orchestrator/ui/static/script.js index 9f6d1088..5215826e 100644 --- a/orchestrator/src/buttercup/orchestrator/ui/static/script.js +++ b/orchestrator/src/buttercup/orchestrator/ui/static/script.js @@ -29,7 +29,19 @@ const elements = { closeDetailModal: document.getElementById('close-detail-modal'), taskForm: document.getElementById('task-form'), fillExampleBtn: document.getElementById('fill-example-btn'), + loadJsonBtn: document.getElementById('load-json-btn'), + loadJsonInput: document.getElementById('load-json-input'), cancelBtn: document.getElementById('cancel-btn'), + submitSarifBtn: document.getElementById('submit-sarif-btn'), + sarifModal: document.getElementById('sarif-modal'), + sarifModalContent: document.querySelector('#sarif-modal .modal-content'), + closeSarifModal: document.getElementById('close-sarif-modal'), + sarifForm: document.getElementById('sarif-form'), + sarifTaskSelect: document.getElementById('sarif-task-select'), + sarifFileInput: document.getElementById('sarif-file-input'), + sarifPreview: document.getElementById('sarif-preview'), + sarifPreviewContent: document.getElementById('sarif-preview-content'), + cancelSarifBtn: document.getElementById('cancel-sarif-btn'), tasksContainer: document.getElementById('tasks-container'), povsContainer: document.getElementById('povs-container'), patchesContainer: document.getElementById('patches-container'), @@ -85,6 +97,28 @@ function setupEventListeners() { } else { console.error('fillExampleBtn element not found'); } + + if (elements.loadJsonBtn && elements.loadJsonInput) { + elements.loadJsonBtn.addEventListener('click', () => { + elements.loadJsonInput.click(); + }); + elements.loadJsonInput.addEventListener('change', handleJsonFileLoad); + } + + elements.submitSarifBtn.addEventListener('click', () => { + openSarifModal(); + }); + + elements.closeSarifModal.addEventListener('click', () => { + elements.sarifModal.style.display = 'none'; + }); + + elements.cancelSarifBtn.addEventListener('click', () => { + elements.sarifModal.style.display = 'none'; + }); + + elements.sarifFileInput.addEventListener('change', handleSarifFilePreview); + elements.sarifForm.addEventListener('submit', handleSarifSubmission); elements.refreshBtn.addEventListener('click', loadDashboard); @@ -129,19 +163,24 @@ function setupEventListeners() { let mousePressedOutside = false; window.addEventListener('mousedown', (event) => { - // Check if mouse was pressed outside both modal contents - if (!elements.taskModalContent.contains(event.target) && !elements.detailModalContent.contains(event.target)) { - mousePressedOutside = true; - } else { - mousePressedOutside = false; - } + // Check if mouse was pressed outside all modal contents + const insideAnyModal = + elements.taskModalContent.contains(event.target) || + elements.detailModalContent.contains(event.target) || + elements.sarifModalContent.contains(event.target); + mousePressedOutside = !insideAnyModal; }); window.addEventListener('mouseup', (event) => { // Only close if mouse was pressed outside and released outside - if (mousePressedOutside && !elements.taskModalContent.contains(event.target) && !elements.detailModalContent.contains(event.target)) { + const insideAnyModal = + elements.taskModalContent.contains(event.target) || + elements.detailModalContent.contains(event.target) || + elements.sarifModalContent.contains(event.target); + if (mousePressedOutside && !insideAnyModal) { elements.taskModal.style.display = 'none'; elements.detailModal.style.display = 'none'; + elements.sarifModal.style.display = 'none'; } }); } @@ -514,6 +553,11 @@ function renderTasks() {
${task.status} + ${task.status === 'active' ? ` + + ` : ''}
@@ -593,6 +637,60 @@ function fillExampleValues() { } } +function handleJsonFileLoad(event) { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(e) { + try { + const data = JSON.parse(e.target.result); + + // Map JSON keys to form field IDs + const fieldMap = { + 'name': 'task-name', + 'challenge_repo_url': 'challenge-repo-url', + 'challenge_repo_head_ref': 'challenge-repo-head-ref', + 'challenge_repo_base_ref': 'challenge-repo-base-ref', + 'fuzz_tooling_url': 'fuzz-tooling-url', + 'fuzz_tooling_ref': 'fuzz-tooling-ref', + 'fuzz_tooling_project_name': 'fuzz-tooling-project-name', + 'duration': 'duration', + }; + + for (const [jsonKey, fieldId] of Object.entries(fieldMap)) { + if (data[jsonKey] !== undefined && data[jsonKey] !== null) { + const field = document.getElementById(fieldId); + if (field) { + field.value = data[jsonKey]; + } + } + } + + if (data.harnesses_included !== undefined) { + const checkbox = document.getElementById('harnesses-included'); + if (checkbox) { + checkbox.checked = !!data.harnesses_included; + } + } + + showNotification( + `Loaded challenge from ${file.name}`, + 'success', + ); + } catch (err) { + showNotification( + `Failed to parse JSON file: ${err.message}`, + 'error', + ); + } + }; + reader.readAsText(file); + + // Reset so the same file can be loaded again + event.target.value = ''; +} + async function handleTaskSubmission(event) { event.preventDefault(); @@ -731,6 +829,17 @@ function renderTaskDetail(task) {
+ ${task.status === 'active' ? ` +
+ + +
+ ` : ''} + ${renderArtifacts('PoVs (Vulnerabilities)', task.povs || [], 'pov')} ${renderArtifacts('Patches', task.patches || [], 'patch')} ${renderArtifacts('Bundles', task.bundles || [], 'bundle')} @@ -834,6 +943,168 @@ function renderArtifact(artifact, type) { `; } +// Cancel task +async function cancelTask(taskId) { + if (!confirm('Are you sure you want to cancel this task?')) { + return; + } + + try { + const response = await fetch(`${API_BASE}/v1/dashboard/tasks/${taskId}`, { + method: 'DELETE', + }); + + const result = await response.json(); + + if (response.ok) { + showNotification( + result.message || 'Task cancellation requested', + null, + result.color, + ); + loadDashboard(); + } else { + const errorMessage = result.message || 'Failed to cancel task'; + showNotification(`Error: ${errorMessage}`, 'error'); + } + } catch (error) { + console.error('Cancel task error:', error); + showNotification('Error cancelling task', 'error'); + } +} + +// Open SARIF modal, optionally pre-selecting a task +function openSarifModal(preselectedTaskId) { + // Populate the task dropdown with active tasks + const activeTaskList = tasks.filter(t => t.status === 'active'); + elements.sarifTaskSelect.innerHTML = + ''; + activeTaskList.forEach(t => { + const label = t.name || t.project_name; + const opt = document.createElement('option'); + opt.value = t.task_id; + opt.textContent = `${label} (${t.task_id.substring(0, 8)}...)`; + elements.sarifTaskSelect.appendChild(opt); + }); + + if (preselectedTaskId) { + elements.sarifTaskSelect.value = preselectedTaskId; + } + + // Reset file input and preview + elements.sarifFileInput.value = ''; + elements.sarifPreview.style.display = 'none'; + elements.sarifPreviewContent.textContent = ''; + + elements.sarifModal.style.display = 'block'; +} + +// Preview SARIF file contents when selected +function handleSarifFilePreview(event) { + const file = event.target.files[0]; + if (!file) { + elements.sarifPreview.style.display = 'none'; + return; + } + + const reader = new FileReader(); + reader.onload = function(e) { + try { + const parsed = JSON.parse(e.target.result); + const runsCount = (parsed.runs || []).length; + const resultsCount = (parsed.runs || []).reduce( + (sum, run) => sum + (run.results || []).length, + 0, + ); + const version = parsed.version || 'unknown'; + const preview = + `SARIF version: ${version}\n` + + `Runs: ${runsCount}\n` + + `Results: ${resultsCount}\n\n` + + JSON.stringify(parsed, null, 2).substring(0, 500); + elements.sarifPreviewContent.textContent = + preview.length >= 500 ? preview + '\n...' : preview; + elements.sarifPreview.style.display = 'block'; + } catch (err) { + elements.sarifPreviewContent.textContent = + 'Invalid JSON: ' + err.message; + elements.sarifPreview.style.display = 'block'; + } + }; + reader.readAsText(file); +} + +// Handle SARIF form submission +async function handleSarifSubmission(event) { + event.preventDefault(); + + const taskId = elements.sarifTaskSelect.value; + if (!taskId) { + showNotification('Please select a task', 'error'); + return; + } + + const file = elements.sarifFileInput.files[0]; + if (!file) { + showNotification('Please select a SARIF file', 'error'); + return; + } + + const submitBtn = elements.sarifForm.querySelector( + 'button[type="submit"]', + ); + const originalText = submitBtn.textContent; + submitBtn.disabled = true; + submitBtn.innerHTML = ' Submitting...'; + + try { + const text = await file.text(); + const sarif = JSON.parse(text); + + const response = await fetch(`${API_BASE}/webhook/sarif`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId, sarif: sarif }), + }); + + const result = await response.json(); + + if (response.ok) { + if ( + result.message && + result.message.includes('failed') + ) { + showNotification(result.message, null, result.color); + } else { + showNotification( + result.message || 'SARIF submitted successfully!', + 'success', + ); + } + elements.sarifModal.style.display = 'none'; + } else { + const errorMessage = + result.message || + result.detail || + 'Failed to submit SARIF'; + showNotification(`Error: ${errorMessage}`, 'error'); + } + } catch (error) { + if (error instanceof SyntaxError) { + showNotification( + 'Invalid SARIF file: not valid JSON', + 'error', + ); + } else { + console.error('Error submitting SARIF:', error); + showNotification('Network error occurred', 'error'); + } + } finally { + submitBtn.disabled = false; + submitBtn.textContent = originalText; + } +} + // Approve patch async function approvePatch(taskId, patchId) { try { diff --git a/orchestrator/src/buttercup/orchestrator/ui/static/styles.css b/orchestrator/src/buttercup/orchestrator/ui/static/styles.css index 037005bf..63907967 100644 --- a/orchestrator/src/buttercup/orchestrator/ui/static/styles.css +++ b/orchestrator/src/buttercup/orchestrator/ui/static/styles.css @@ -466,6 +466,11 @@ body { background-color: #c82333; } +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; +} + /* Modal styles */ .modal { display: none;