diff --git a/orchestrator/src/buttercup/orchestrator/ui/competition_api/main.py b/orchestrator/src/buttercup/orchestrator/ui/competition_api/main.py index f38d3a1a..8751172c 100644 --- a/orchestrator/src/buttercup/orchestrator/ui/competition_api/main.py +++ b/orchestrator/src/buttercup/orchestrator/ui/competition_api/main.py @@ -56,7 +56,7 @@ _settings: Settings | None = None _database_manager: DatabaseManager | None = None # Compatibility - keeping dashboard_stats as backup/cache -dashboard_stats = {"activeTasks": 0, "totalPovs": 0, "totalPatches": 0, "totalBundles": 0} +dashboard_stats = {"activeTasks": 0, "totalPovs": 0, "totalPatches": 0, "totalBundles": 0, "failedTasks": 0} # Dashboard models @@ -77,12 +77,19 @@ class TaskInfo(BaseModel): bundles: List[Dict[str, Any]] = [] created_at: str + # CRS submission status and error information + crs_submission_status: Optional[str] = None # 'pending', 'success', 'failed' + crs_submission_error: Optional[str] = None + crs_error_details: Optional[str] = None # JSON string + crs_submission_timestamp: Optional[str] = None + class DashboardStats(BaseModel): activeTasks: int totalPovs: int totalPatches: int totalBundles: int + failedTasks: int class Challenge(BaseModel): @@ -291,12 +298,23 @@ if static_dir.exists(): # Utility functions for task management -def calculate_task_status(deadline: datetime) -> str: - """Calculate task status based on deadline.""" +def calculate_task_status(task: Task) -> str: + """Calculate task status based on deadline and CRS submission status.""" try: - now = datetime.now(deadline.tzinfo) + # First check if the task failed to submit to CRS + crs_status = getattr(task, "crs_submission_status", None) + if crs_status == "failed": + return "failed" + + # Then check deadline + deadline = getattr(task, "deadline", None) + if deadline is None: + return "active" # Default to active if no deadline + + now = datetime.now(deadline.tzinfo) if deadline.tzinfo else datetime.now() if now > deadline: return "expired" + return "active" except Exception: return "active" # Default to active if parsing fails @@ -310,10 +328,14 @@ def update_dashboard_stats(database_manager: DatabaseManager) -> None: total_povs = 0 total_patches = 0 total_bundles = 0 + failed_tasks_count = 0 with database_manager.get_all_tasks() as tasks: for task in tasks: - if calculate_task_status(task.deadline) == "active": + task_status = calculate_task_status(task) + if task_status == "active": active_count += 1 + if task_status == "failed": + failed_tasks_count += 1 total_povs += len(task.povs) total_patches += len(task.patches) @@ -325,76 +347,104 @@ def update_dashboard_stats(database_manager: DatabaseManager) -> None: "totalPovs": total_povs, "totalPatches": total_patches, "totalBundles": total_bundles, + "failedTasks": failed_tasks_count, } ) def pov_to_pov_info(pov: POV) -> dict[str, Any]: - return { - "pov_id": pov.pov_id, - "timestamp": pov.created_at, - "architecture": pov.architecture, - "engine": pov.engine, - "fuzzer_name": pov.fuzzer_name, - "sanitizer": pov.sanitizer, - "testcase": base64.b64encode(pov.testcase), - } + try: + return { + "pov_id": getattr(pov, "pov_id", "unknown"), + "timestamp": getattr(pov, "created_at", datetime.now()), + "architecture": getattr(pov, "architecture", "unknown"), + "engine": getattr(pov, "engine", "unknown"), + "fuzzer_name": getattr(pov, "fuzzer_name", "unknown"), + "sanitizer": getattr(pov, "sanitizer", "unknown"), + "testcase": base64.b64encode(getattr(pov, "testcase", b"")), + } + except Exception as e: + logger.error(f"Error converting POV to info: {e}") + return {"pov_id": "error", "error": str(e)} def patch_to_patch_info(patch: Patch) -> dict[str, Any]: - return { - "patch_id": patch.patch_id, - "timestamp": patch.created_at, - "patch": base64.b64encode(patch.patch.encode("utf-8", errors="ignore")), - } + try: + return { + "patch_id": getattr(patch, "patch_id", "unknown"), + "timestamp": getattr(patch, "created_at", datetime.now()), + "patch": base64.b64encode(getattr(patch, "patch", "").encode("utf-8", errors="ignore")), + } + except Exception as e: + logger.error(f"Error converting patch to info: {e}") + return {"patch_id": "error", "error": str(e)} def bundle_to_bundle_info(bundle: Bundle) -> dict[str, Any]: - return { - "bundle_id": bundle.bundle_id, - "timestamp": bundle.created_at, - "description": bundle.description, - "broadcast_sarif_id": bundle.broadcast_sarif_id, - "freeform_id": bundle.freeform_id, - "patch_id": bundle.patch_id, - "pov_id": bundle.pov_id, - "submitted_sarif_id": bundle.submitted_sarif_id, - } + try: + return { + "bundle_id": getattr(bundle, "bundle_id", "unknown"), + "timestamp": getattr(bundle, "created_at", datetime.now()), + "description": getattr(bundle, "description", ""), + "broadcast_sarif_id": getattr(bundle, "broadcast_sarif_id", None), + "freeform_id": getattr(bundle, "freeform_id", None), + "patch_id": getattr(bundle, "patch_id", None), + "pov_id": getattr(bundle, "pov_id", None), + "submitted_sarif_id": getattr(bundle, "submitted_sarif_id", None), + } + except Exception as e: + logger.error(f"Error converting bundle to info: {e}") + return {"bundle_id": "error", "error": str(e)} def task_to_task_info(task: Task) -> TaskInfo: """Convert a task to a TaskInfo object.""" - povs = [] - for pov in task.povs: - povs.append(pov_to_pov_info(pov)) + try: + povs = [] + for pov in task.povs: + povs.append(pov_to_pov_info(pov)) - patches = [] - for patch in task.patches: - patches.append(patch_to_patch_info(patch)) + patches = [] + for patch in task.patches: + patches.append(patch_to_patch_info(patch)) - bundles = [] - for bundle in task.bundles: - bundles.append(bundle_to_bundle_info(bundle)) + bundles = [] + for bundle in task.bundles: + bundles.append(bundle_to_bundle_info(bundle)) - task_data = { - "task_id": task.task_id, - "name": task.name, - "project_name": task.project_name, - "status": calculate_task_status(task.deadline), - "duration": task.duration, - "deadline": datetime.isoformat(task.deadline), - "challenge_repo_url": task.challenge_repo_url, - "challenge_repo_head_ref": task.challenge_repo_head_ref, - "challenge_repo_base_ref": task.challenge_repo_base_ref, - "fuzz_tooling_url": task.fuzz_tooling_url, - "fuzz_tooling_ref": task.fuzz_tooling_ref, - "created_at": datetime.isoformat(task.created_at), - "povs": povs, - "patches": patches, - "bundles": bundles, - } + task_data = { + "task_id": task.task_id, + "name": getattr(task, "name", None), + "project_name": getattr(task, "project_name", "Unknown"), + "status": calculate_task_status(task), + "duration": getattr(task, "duration", 0), + "deadline": getattr(task, "deadline", datetime.now()).isoformat() + if getattr(task, "deadline", None) + else None, + "challenge_repo_url": getattr(task, "challenge_repo_url", None), + "challenge_repo_head_ref": getattr(task, "challenge_repo_head_ref", None), + "challenge_repo_base_ref": getattr(task, "challenge_repo_base_ref", None), + "fuzz_tooling_url": getattr(task, "fuzz_tooling_url", None), + "fuzz_tooling_ref": getattr(task, "fuzz_tooling_ref", None), + "created_at": getattr(task, "created_at", datetime.now()).isoformat() + if getattr(task, "created_at", None) + else None, + "povs": povs, + "patches": patches, + "bundles": bundles, + # CRS submission status and error information + "crs_submission_status": getattr(task, "crs_submission_status", None), + "crs_submission_error": getattr(task, "crs_submission_error", None), + "crs_error_details": getattr(task, "crs_error_details", None), + "crs_submission_timestamp": getattr(task, "crs_submission_timestamp", datetime.fromtimestamp(0)).isoformat() + if getattr(task, "crs_submission_timestamp", None) + else None, + } - return TaskInfo(**task_data) + return TaskInfo(**task_data) + except Exception as e: + logger.error(f"Error in task_to_task_info for task {getattr(task, 'task_id', 'unknown')}: {e}", exc_info=True) + raise def _create_task( @@ -418,6 +468,8 @@ def _create_task( now = datetime.now() deadline = now + timedelta(seconds=challenge.duration) + # Always create the task in the database first + logger.info(f"Creating task {task_id} in database for challenge {challenge.name}") database_manager.create_task( task_id=task_id, name=name, @@ -431,18 +483,104 @@ def _create_task( fuzz_tooling_url=challenge.fuzz_tooling_url, fuzz_tooling_ref=challenge.fuzz_tooling_ref, ) + logger.info(f"Task {task_id} created successfully in database") # Send task to CRS via POST /v1/task endpoint - if crs_client.submit_task(task): + logger.info(f"Submitting task {task_id} to CRS...") + crs_response = crs_client.submit_task(task) + + # Update the task with CRS submission results + if crs_response.success: logger.info(f"Task {task_id} submitted successfully to CRS") - return Message(message=f"Task {task_id} created and submitted to CRS for challenge {challenge.name}") + database_manager.update_task_crs_status(task_id=task_id, crs_submission_status="success") + logger.info(f"Task {task_id} CRS status updated to 'success'") + return Message( + message=f"Task {task_id} created and submitted successfully to CRS for challenge {challenge.name}" + ) else: logger.error(f"Failed to submit task {task_id} to CRS") - return Error(message="Failed to submit task to CRS") + + # Generate user-friendly error message + error_message = crs_response.get_user_friendly_error_message("Failed to submit task to CRS") + logger.error(f"Task {task_id} error message: {error_message}") + + # Update the task with error information + logger.info(f"Updating task {task_id} CRS status to 'failed' with error details") + database_manager.update_task_crs_status( + task_id=task_id, + crs_submission_status="failed", + crs_submission_error=error_message, + crs_error_details=crs_response.error_details, + ) + logger.info(f"Task {task_id} CRS status updated to 'failed' with error details") + + # Verify the task was updated correctly + with database_manager.get_task(task_id) as updated_task: + if updated_task: + logger.info(f"Verified task {task_id} in database:") + logger.info(f" - CRS status: {getattr(updated_task, 'crs_submission_status', 'N/A')}") + logger.info(f" - CRS error: {getattr(updated_task, 'crs_submission_error', 'N/A')}") + logger.info(f" - CRS error details: {getattr(updated_task, 'crs_error_details', 'N/A')}") + else: + logger.error(f"Task {task_id} not found in database after update!") + + # Return success message since the task was created, but include the CRS error + return Message( + message=f"Task {task_id} created but failed to submit to CRS. " + f"Task is visible in the dashboard with error details. " + f"Error: {error_message}", + color="error", + ) except Exception as e: logger.error(f"Error creating task for challenge {challenge.name}: {e}") - return Error(message=f"Failed to create task: {str(e)}") + + # Create a failed task in the database even when the challenge service fails + try: + # Generate a unique task ID for the failed task + failed_task_id = str(uuid.uuid4()) + name = challenge.name or f"failed-{failed_task_id[:8]}" + now = datetime.now() + deadline = now + timedelta(seconds=challenge.duration) + + logger.info(f"Creating failed task {failed_task_id} in database for challenge {challenge.name}") + database_manager.create_task( + task_id=failed_task_id, + name=name, + project_name=challenge.fuzz_tooling_project_name, + status="failed", + duration=challenge.duration, + deadline=deadline, + challenge_repo_url=challenge.challenge_repo_url, + challenge_repo_head_ref=challenge.challenge_repo_head_ref, + challenge_repo_base_ref=challenge.challenge_repo_base_ref, + fuzz_tooling_url=challenge.fuzz_tooling_url, + fuzz_tooling_ref=challenge.fuzz_tooling_ref, + ) + + # Mark it as failed with the error details + error_message = f"Failed to create task: {str(e)}" + database_manager.update_task_crs_status( + task_id=failed_task_id, + crs_submission_status="failed", + crs_submission_error=error_message, + crs_error_details={"exception": str(e), "error_type": "challenge_service_failure"}, + ) + + logger.info(f"Failed task {failed_task_id} created in database with error details") + + # Return a message indicating the task was created but failed + return Message( + message=f"Task {failed_task_id} created but failed during setup. " + f"Task is visible in the dashboard with error details. " + f"Error: {error_message}", + color="error", + ) + + except Exception as db_error: + logger.error(f"Failed to create failed task in database: {db_error}") + # If we can't even create the failed task, return the original error + return Error(message=f"Failed to create task: {str(e)}") def _create_sarif_broadcast( @@ -460,10 +598,22 @@ def _create_sarif_broadcast( sarif = body["sarif"] broadcast = challenge_service.create_sarif_broadcast(task_id, sarif) - if crs_client.submit_sarif_broadcast(broadcast): + + crs_response = crs_client.submit_sarif_broadcast(broadcast) + + if crs_response.success: return Message(message=f"SARIF Broadcast for Task {task_id} created and submitted to CRS") else: - return Error(message=f"Failed to submit SARIF Broadcast for Task {task_id} to CRS") + # Use the helper method to generate user-friendly error message + error_message = crs_response.get_user_friendly_error_message( + f"Failed to submit SARIF Broadcast for Task {task_id} to CRS" + ) + + # Return a message with the error details for better user experience + return Message( + message=f"SARIF Broadcast for Task {task_id} created but failed to submit to CRS. Error: {error_message}", + color="error", + ) @app.get("/v1/ping/", response_model=PingResponse, tags=["ping"]) @@ -536,6 +686,74 @@ def get_dashboard_tasks(database_manager: DatabaseManager = Depends(get_database return tasks_list +@app.get("/v1/dashboard/tasks/failed", response_model=List[TaskInfo], tags=["dashboard"]) +def get_failed_tasks(database_manager: DatabaseManager = Depends(get_database_manager)) -> List[TaskInfo]: + """ + Get list of failed tasks for debugging + """ + logger.info("get_failed_tasks endpoint called") + + try: + failed_tasks = database_manager.get_tasks_by_crs_status("failed") + logger.info(f"Found {len(failed_tasks)} failed tasks in database") + + if failed_tasks: + logger.info(f"First failed task ID: {failed_tasks[0].task_id}") + logger.info(f"First failed task CRS status: {getattr(failed_tasks[0], 'crs_submission_status', 'N/A')}") + logger.info(f"First failed task CRS error: {getattr(failed_tasks[0], 'crs_submission_error', 'N/A')}") + + tasks_list = [] + for task in failed_tasks: + try: + task_info = task_to_task_info(task) + tasks_list.append(task_info) + logger.info(f"Converted task {task.task_id} to TaskInfo with status: {task_info.status}") + except Exception as task_error: + logger.error(f"Error converting task {task.task_id} to TaskInfo: {task_error}", exc_info=True) + # Skip this task and continue with others + continue + + # Sort by created_at descending (newest first) + tasks_list.sort(key=lambda x: x.created_at, reverse=True) + logger.info(f"Returning {len(tasks_list)} failed tasks") + + return tasks_list + + except Exception as e: + logger.error(f"Error in get_failed_tasks: {e}", exc_info=True) + raise + + +@app.get("/v1/dashboard/tasks/active", response_model=List[TaskInfo], tags=["dashboard"]) +def get_active_tasks(database_manager: DatabaseManager = Depends(get_database_manager)) -> List[TaskInfo]: + """ + Get list of active tasks + """ + active_tasks = database_manager.get_tasks_by_status("active") + tasks_list = [] + for task in active_tasks: + tasks_list.append(task_to_task_info(task)) + + # Sort by created_at descending (newest first) + tasks_list.sort(key=lambda x: x.created_at, reverse=True) + return tasks_list + + +@app.get("/v1/dashboard/tasks/expired", response_model=List[TaskInfo], tags=["dashboard"]) +def get_expired_tasks(database_manager: DatabaseManager = Depends(get_database_manager)) -> List[TaskInfo]: + """ + Get list of expired tasks + """ + expired_tasks = database_manager.get_tasks_by_status("expired") + tasks_list = [] + for task in expired_tasks: + tasks_list.append(task_to_task_info(task)) + + # Sort by created_at descending (newest first) + tasks_list.sort(key=lambda x: x.created_at, reverse=True) + return tasks_list + + @app.get("/v1/dashboard/tasks/{task_id}", response_model=TaskInfo, tags=["dashboard"]) def get_dashboard_task(task_id: str, database_manager: DatabaseManager = Depends(get_database_manager)) -> TaskInfo: """ @@ -548,6 +766,36 @@ def get_dashboard_task(task_id: str, database_manager: DatabaseManager = Depends return task_to_task_info(task) +@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 + """ + with database_manager.get_task(task_id) as task: + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + import json + + # Parse error details if present + error_details = None + if hasattr(task, "crs_error_details") and task.crs_error_details: + try: + error_details = json.loads(task.crs_error_details) + except json.JSONDecodeError: + error_details = {"raw": task.crs_error_details} + + return { + "task_id": task_id, + "crs_submission_status": getattr(task, "crs_submission_status", None), + "crs_submission_error": getattr(task, "crs_submission_error", None), + "crs_error_details": error_details, + "crs_submission_timestamp": getattr(task, "crs_submission_timestamp", datetime.fromtimestamp(0)).isoformat() + if getattr(task, "crs_submission_timestamp", None) + else None, + } + + @app.get( "/v1/request/list/", response_model=RequestListResponse, diff --git a/orchestrator/src/buttercup/orchestrator/ui/competition_api/models/types.py b/orchestrator/src/buttercup/orchestrator/ui/competition_api/models/types.py index a3de59fa..40cb9c5b 100644 --- a/orchestrator/src/buttercup/orchestrator/ui/competition_api/models/types.py +++ b/orchestrator/src/buttercup/orchestrator/ui/competition_api/models/types.py @@ -49,7 +49,8 @@ class FuzzingEngine(Enum): class Message(BaseModel): - message: Optional[str] = None + message: str + color: Optional[str] = "default" # "default", "success", "warning", "error" class POVSubmission(BaseModel): 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 1cf9f32f..e6d3839e 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 @@ -2,19 +2,190 @@ from __future__ import annotations import logging import requests +from typing import Optional, Dict, Any from buttercup.orchestrator.ui.competition_api.models.crs_types import Task, SARIFBroadcast logger = logging.getLogger(__name__) +class CRSResponse: + """Response from CRS API calls with detailed status and error information.""" + + def __init__( + self, success: bool, status_code: int, response_text: str = "", error_details: Optional[Dict[str, Any]] = None + ): + self.success = success + self.status_code = status_code + self.response_text = response_text + self.error_details = error_details or {} + + def get_user_friendly_error_message(self, base_message: str = "Operation failed") -> str: + """ + Generate a user-friendly error message from the response details. + + Args: + base_message: Base message to start with + + Returns: + Formatted error message suitable for display to users + """ + if self.success: + return "" + + error_message = base_message + + # Add specific error information + if self.error_details.get("error_message"): + error_message += f": {self.error_details['error_message']}" + + # Add validation errors if present + validation_errors = self.error_details.get("validation_errors", {}) + if validation_errors: + if isinstance(validation_errors, dict): + # Format validation errors in a user-friendly way + formatted_errors = [] + for field, error in validation_errors.items(): + if isinstance(error, list): + formatted_errors.append(f"{field}: {', '.join(error)}") + else: + formatted_errors.append(f"{field}: {error}") + error_message += f". Validation errors: {'; '.join(formatted_errors)}" + else: + error_message += f". Validation errors: {validation_errors}" + + # Add error code if present + if self.error_details.get("error_code"): + error_message += f" (Error code: {self.error_details['error_code']})" + + # Add HTTP status code information if it's not a standard success code + if self.status_code not in (200, 202): + error_message += f" (HTTP status: {self.status_code})" + + return error_message + + def log_detailed_response(self, logger_instance: logging.Logger, operation: str = "CRS operation") -> None: + """ + Log detailed response information for debugging purposes. + + Args: + logger_instance: Logger instance to use + operation: Description of the operation being logged + """ + if self.success: + logger_instance.info(f"{operation} completed successfully (HTTP {self.status_code})") + else: + logger_instance.error(f"{operation} failed (HTTP {self.status_code})") + logger_instance.error(f"Response text: {self.response_text}") + + if self.error_details: + logger_instance.error(f"Error details: {self.error_details}") + + # Log specific error information + if self.error_details.get("error_message"): + logger_instance.error(f"Error message: {self.error_details['error_message']}") + + if self.error_details.get("error_code"): + logger_instance.error(f"Error code: {self.error_details['error_code']}") + + if self.error_details.get("validation_errors"): + logger_instance.error(f"Validation errors: {self.error_details['validation_errors']}") + + if self.error_details.get("status"): + logger_instance.error(f"Status: {self.error_details['status']}") + + if self.error_details.get("success_indicator") is not None: + logger_instance.error(f"Success indicator: {self.error_details['success_indicator']}") + + if self.error_details.get("accepted") is not None: + logger_instance.error(f"Accepted: {self.error_details['accepted']}") + + if self.error_details.get("valid") is not None: + logger_instance.error(f"Valid: {self.error_details['valid']}") + + @classmethod + def from_response(cls, response: requests.Response) -> CRSResponse: + """Create a CRSResponse from a requests.Response object.""" + try: + # Try to parse JSON response for detailed error information + response_data = response.json() + + # Check if the response indicates success despite HTTP status + # Look for common error indicators in the response body + if isinstance(response_data, dict): + # Check for various error fields that might be present in the response + error_message = ( + response_data.get("error", "") + or response_data.get("message", "") + or response_data.get("detail", "") + or response_data.get("reason", "") + ) + error_code = response_data.get("error_code", "") or response_data.get("code", "") + validation_errors = ( + response_data.get("validation_errors", {}) + or response_data.get("errors", {}) + or response_data.get("field_errors", {}) + or response_data.get("constraint_violations", {}) + ) + + # Check for success/failure indicators in the response + status = response_data.get("status", "").lower() + success_indicator = response_data.get("success", None) + + # Look for additional failure indicators + has_failure_indicators = ( + error_message + or error_code + or validation_errors + or status in ["error", "failed", "failure", "invalid", "rejected"] + or success_indicator is False + or response_data.get("accepted", True) is False + or response_data.get("valid", True) is False + ) + + # Determine if this is actually a success or failure + # Even with 200/202 status, the response body might indicate failure + is_success = response.status_code in (200, 202) and not has_failure_indicators + + return cls( + success=is_success, + status_code=response.status_code, + response_text=response.text, + error_details={ + "error_message": error_message, + "error_code": error_code, + "validation_errors": validation_errors, + "status": status, + "success_indicator": success_indicator, + "accepted": response_data.get("accepted"), + "valid": response_data.get("valid"), + "raw_response": response_data, + }, + ) + else: + # Non-JSON response, treat as success if status is 200/202 + return cls( + success=response.status_code in (200, 202), + status_code=response.status_code, + response_text=response.text, + ) + + except (ValueError, TypeError): + # Response is not valid JSON, treat as success if status is 200/202 + return cls( + success=response.status_code in (200, 202), + status_code=response.status_code, + response_text=response.text, + ) + + class CRSClient: def __init__(self, crs_base_url: str, username: str | None = None, password: str | None = None) -> None: self.crs_base_url = crs_base_url.rstrip("/") self.username = username self.password = password - def submit_task(self, task: Task) -> bool: + def submit_task(self, task: Task) -> CRSResponse: """ Submit a task to the CRS via POST /v1/task endpoint @@ -22,7 +193,7 @@ class CRSClient: task: Task object to submit Returns: - True if successful, False otherwise + CRSResponse object with detailed status and error information """ url = f"{self.crs_base_url}/v1/task/" @@ -42,18 +213,19 @@ class CRSClient: timeout=30, ) - if response.status_code in (202, 200): - logger.info(f"Task {task.tasks[0].task_id} submitted successfully to CRS") - return True - else: - logger.error(f"Failed to submit task to CRS. Status: {response.status_code}, Response: {response.text}") - return False + # Create detailed response object + crs_response = CRSResponse.from_response(response) + + # Log detailed response information + crs_response.log_detailed_response(logger, f"Task submission for {task.tasks[0].task_id}") + + return crs_response except Exception as e: logger.error(f"Error submitting task to CRS: {e}") - return False + return CRSResponse(success=False, status_code=0, response_text=str(e), error_details={"exception": str(e)}) - def submit_sarif_broadcast(self, broadcast: SARIFBroadcast) -> bool: + def submit_sarif_broadcast(self, broadcast: SARIFBroadcast) -> CRSResponse: """ Submit a SARIF Broadcast to the CRS via POST /v1/sarif/ endpoint """ @@ -75,18 +247,19 @@ class CRSClient: timeout=30, ) - if response.status_code in (202, 200): - logger.info("SARIF Broadcasts submitted successfully to CRS") - return True - else: - logger.error( - f"Failed to submit SARIF Broadcasts to CRS. Status: {response.status_code}, Response: {response.text}" - ) - return False + # Create detailed response object + crs_response = CRSResponse.from_response(response) + + # Log detailed response information + crs_response.log_detailed_response( + logger, f"SARIF Broadcast submission for {len(broadcast.broadcasts)} tasks" + ) + + return crs_response except Exception as e: logger.error(f"Error submitting SARIF Broadcasts to CRS: {e}") - return False + return CRSResponse(success=False, status_code=0, response_text=str(e), error_details={"exception": str(e)}) def ping(self) -> bool: """ diff --git a/orchestrator/src/buttercup/orchestrator/ui/database.py b/orchestrator/src/buttercup/orchestrator/ui/database.py index e850156f..1eba81c5 100644 --- a/orchestrator/src/buttercup/orchestrator/ui/database.py +++ b/orchestrator/src/buttercup/orchestrator/ui/database.py @@ -43,6 +43,12 @@ class Task(Base): fuzz_tooling_ref: Mapped[str] = mapped_column(String) created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) + # Error tracking fields + crs_submission_error: Mapped[str | None] = mapped_column(Text, nullable=True) + crs_error_details: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON string for detailed error info + crs_submission_status: Mapped[str | None] = mapped_column(String, nullable=True) # 'pending', 'success', 'failed' + crs_submission_timestamp: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + # Relationships povs: Mapped[list["POV"]] = relationship("POV", back_populates="task", cascade="all, delete-orphan") patches: Mapped[list["Patch"]] = relationship("Patch", back_populates="task", cascade="all, delete-orphan") @@ -133,6 +139,35 @@ class DatabaseManager: yield task session.commit() + def get_tasks_by_crs_status(self, crs_status: str) -> list[Task]: + """Get all tasks with a specific CRS submission status.""" + with self.get_session() as session: + tasks = session.query(Task).filter(Task.crs_submission_status == crs_status).all() + session.commit() + return tasks + + def get_tasks_by_status(self, status: str) -> list[Task]: + """Get all tasks with a specific overall status (active, expired, failed).""" + with self.get_session() as session: + if status == "failed": + # For failed status, check CRS submission status + tasks = session.query(Task).filter(Task.crs_submission_status == "failed").all() + else: + # For other statuses, we need to calculate based on deadline + from datetime import datetime + + now = datetime.now() + + if status == "active": + tasks = session.query(Task).filter(Task.deadline > now).all() + elif status == "expired": + tasks = session.query(Task).filter(Task.deadline <= now).all() + else: + tasks = [] + + session.commit() + return tasks + @contextmanager def get_povs_for_task(self, task_id: str) -> Iterator[list[POV]]: """Context manager: yields all POVs for a task with session open.""" @@ -250,6 +285,32 @@ class DatabaseManager: logger.info(f"Created task: {task.task_id}") return task + def update_task_crs_status( + self, + *, + task_id: str, + crs_submission_status: str, + crs_submission_error: str | None = None, + crs_error_details: dict | None = None, + ) -> None: + """Update the CRS submission status and error information for a task.""" + import json + + with self.get_session() as session: + task = session.query(Task).filter(Task.task_id == task_id).first() + if task: + task.crs_submission_status = crs_submission_status + task.crs_submission_error = crs_submission_error + task.crs_submission_timestamp = datetime.now() + + if crs_error_details: + task.crs_error_details = json.dumps(crs_error_details, indent=2) + + session.commit() + logger.info(f"Updated CRS status for task {task_id}: {crs_submission_status}") + else: + logger.warning(f"Task {task_id} not found for CRS status update") + # POV operations def create_pov( self, *, task_id: str, architecture: str, engine: str, fuzzer_name: str, sanitizer: str, testcase: bytes diff --git a/orchestrator/src/buttercup/orchestrator/ui/static/index.html b/orchestrator/src/buttercup/orchestrator/ui/static/index.html index 17997857..17e5fa00 100644 --- a/orchestrator/src/buttercup/orchestrator/ui/static/index.html +++ b/orchestrator/src/buttercup/orchestrator/ui/static/index.html @@ -26,6 +26,11 @@
-
Currently running
+
+

Failed Tasks

+
-
+
CRS submissions failed
+

Total PoVs

-
@@ -61,6 +66,7 @@ +
diff --git a/orchestrator/src/buttercup/orchestrator/ui/static/script.js b/orchestrator/src/buttercup/orchestrator/ui/static/script.js index 46d335d5..fc192f8f 100644 --- a/orchestrator/src/buttercup/orchestrator/ui/static/script.js +++ b/orchestrator/src/buttercup/orchestrator/ui/static/script.js @@ -32,13 +32,15 @@ const elements = { patchesContainer: document.getElementById('patches-container'), statusFilter: document.getElementById('status-filter'), activeTasks: document.getElementById('active-tasks'), + failedTasks: document.getElementById('failed-tasks'), totalPovs: document.getElementById('total-povs'), totalPatches: document.getElementById('total-patches'), totalBundles: document.getElementById('total-bundles'), detailTitle: document.getElementById('detail-title'), detailContent: document.getElementById('detail-content'), tabButtons: document.querySelectorAll('.tab-button'), - tabPanes: document.querySelectorAll('.tab-pane') + tabPanes: document.querySelectorAll('.tab-pane'), + notifications: document.getElementById('notifications') }; // Initialize the dashboard @@ -205,7 +207,8 @@ async function loadDashboard() { // Load tasks from API async function loadTasks() { try { - const response = await fetch(`${API_BASE}/v1/dashboard/tasks`); + // Add timestamp to prevent caching + const response = await fetch(`${API_BASE}/v1/dashboard/tasks?t=${Date.now()}`); if (response.ok) { tasks = await response.json(); } else { @@ -308,6 +311,7 @@ function calculateStatsFromTasks() { function updateDashboard() { // Update stats elements.activeTasks.textContent = dashboardStats.activeTasks; + elements.failedTasks.textContent = dashboardStats.failedTasks || 0; elements.totalPovs.textContent = dashboardStats.totalPovs; elements.totalPatches.textContent = dashboardStats.totalPatches; elements.totalBundles.textContent = dashboardStats.totalBundles; @@ -336,6 +340,11 @@ async function loadAndRenderPatches() { // Render PoVs list function renderPovs() { + if (!elements.povsContainer) { + console.error('PoVs container not found!'); + return; + } + if (allPovs.length === 0) { elements.povsContainer.innerHTML = `
@@ -395,20 +404,26 @@ function renderPatches() { // Render tasks list function renderTasks() { - const filteredTasks = filterTasksByStatus(); + if (!elements.tasksContainer) { + console.error('Tasks container not found!'); + return; + } + + const filteredTasks = filterTasksByStatus(tasks); if (filteredTasks.length === 0) { elements.tasksContainer.innerHTML = `
-
📋
+
📝

No tasks found

+

Create a new task to get started

`; return; } elements.tasksContainer.innerHTML = filteredTasks.map(task => ` -
+
${task.name || task.project_name}
ID: ${task.task_id}
@@ -418,6 +433,11 @@ function renderTasks() { Created: ${formatTimestamp(task.created_at)} Deadline: ${formatTimestamp(task.deadline)}
+ ${task.crs_submission_error ? ` +
+ Error: ${task.crs_submission_error} +
+ ` : ''}
${task.status} @@ -482,12 +502,29 @@ async function handleExampleTaskSubmission() { body: JSON.stringify(exampleTaskData) }); + const result = await response.json(); + console.log('Example task submission response:', result); + console.log('Response message:', result.message); + console.log('Response color:', result.color); + if (response.ok) { - const result = await response.json(); - showNotification('Example libpng task submitted successfully!', 'success'); - - // Refresh dashboard after a short delay - setTimeout(loadDashboard, 1000); + // Check if the response indicates a CRS submission failure or setup failure + if (result.message && (result.message.includes('failed to submit to CRS') || result.message.includes('failed during setup'))) { + console.log('Example task creation/submission failed, showing error notification'); + // Task was created but failed - show notification with proper color + showNotification(result.message, null, result.color); + + // Force refresh failed tasks and main tasks list + setTimeout(async () => { + await forceRefreshFailedTasks(); + }, 1000); + } else { + // Complete success + showNotification(result.message || 'Example libpng task submitted successfully!', 'success'); + + // Refresh dashboard after a short delay + setTimeout(loadDashboard, 1000); + } } else { const error = await response.json(); showNotification(`Error: ${error.message || 'Failed to submit example task'}`, 'error'); @@ -527,17 +564,37 @@ async function handleTaskSubmission(event) { body: JSON.stringify(taskData) }); + const result = await response.json(); + console.log('Task submission response:', result); + console.log('Response message:', result.message); + console.log('Response color:', result.color); + if (response.ok) { - const result = await response.json(); - showNotification('Task submitted successfully!', 'success'); - elements.taskModal.style.display = 'none'; - elements.taskForm.reset(); - - // Refresh dashboard after a short delay - setTimeout(loadDashboard, 1000); + // Check if the response indicates a CRS submission failure or setup failure + if (result.message && (result.message.includes('failed to submit to CRS') || result.message.includes('failed during setup'))) { + console.log('Task creation/submission failed, showing error notification'); + // Task was created but failed - show notification with proper color + showNotification(result.message, null, result.color); + elements.taskModal.style.display = 'none'; + elements.taskForm.reset(); + + // Force refresh failed tasks and main tasks list + setTimeout(async () => { + await forceRefreshFailedTasks(); + }, 1000); + } else { + // Complete success + showNotification(result.message || 'Task submitted successfully!', 'success'); + elements.taskModal.style.display = 'none'; + elements.taskForm.reset(); + + // Refresh dashboard after a short delay + setTimeout(loadDashboard, 1000); + } } else { - const error = await response.json(); - showNotification(`Error: ${error.message || 'Failed to submit task'}`, 'error'); + // HTTP error - show error message + const errorMessage = result.message || result.detail || 'Failed to submit task'; + showNotification(`Error: ${errorMessage}`, 'error'); } } catch (error) { console.error('Error submitting task:', error); @@ -578,28 +635,20 @@ function renderTaskDetail(task) {

Task Information

-
Task ID:
-
${task.task_id}
Name:
-
${task.name || 'N/A'}
+
${task.name || task.project_name}
+
ID:
+
${task.task_id}
Project:
${task.project_name}
Status:
${task.status}
-
Duration:
-
${formatDuration(task.duration)}
Created:
${formatTimestamp(task.created_at)}
Deadline:
${formatTimestamp(task.deadline)}
-
Repository:
-
${task.challenge_repo_url || 'N/A'}
-
Head Ref:
-
${task.challenge_repo_head_ref || 'N/A'}
-
Base Ref:
-
${task.challenge_repo_base_ref || 'N/A'}
@@ -825,6 +874,9 @@ function renderArtifactDetail(detailData, type) { // Not base64, use as is } } + const patchPreview = patchContent.length > 300 + ? patchContent.substring(0, 300) + '...' + : patchContent; specificContent = `
Patch Size:
${originalSize} characters (${patchContent.length} decoded)
@@ -879,44 +931,29 @@ function formatTimestamp(timestamp) { return new Date(timestamp).toLocaleString(); } -function showNotification(message, type = 'info') { - // Create a simple notification system +function showNotification(message, type = 'info', color = null) { + // If color is provided from backend Message, use it to override type + if (color === 'error') { + type = 'error'; + } else if (color === 'warning') { + type = 'warning'; + } else if (color === 'success') { + type = 'success'; + } + const notification = document.createElement('div'); notification.className = `notification notification-${type}`; notification.textContent = message; - // Add notification styles if not already added - if (!document.querySelector('style[data-notifications]')) { - const style = document.createElement('style'); - style.setAttribute('data-notifications', 'true'); - style.textContent = ` - .notification { - position: fixed; - top: 20px; - right: 20px; - padding: 1rem 1.5rem; - border-radius: 4px; - color: white; - font-weight: 500; - z-index: 2000; - animation: slideIn 0.3s ease; - } - .notification-success { background-color: #4caf50; } - .notification-error { background-color: #f44336; } - .notification-info { background-color: #2196f3; } - @keyframes slideIn { - from { transform: translateX(100%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } - } - `; - document.head.appendChild(style); - } + // Add to notifications container + const container = document.getElementById('notifications') || document.body; + container.appendChild(notification); - document.body.appendChild(notification); - - // Remove notification after 5 seconds + // Auto-remove after 5 seconds setTimeout(() => { - notification.remove(); + if (notification.parentNode) { + notification.remove(); + } }, 5000); } @@ -1019,4 +1056,4 @@ function getMockTasks() { bundles: [] } ]; -} +} \ No newline at end of file diff --git a/orchestrator/src/buttercup/orchestrator/ui/static/styles.css b/orchestrator/src/buttercup/orchestrator/ui/static/styles.css index 9f6ed645..14cd06a7 100644 --- a/orchestrator/src/buttercup/orchestrator/ui/static/styles.css +++ b/orchestrator/src/buttercup/orchestrator/ui/static/styles.css @@ -55,15 +55,26 @@ body { .card { background: white; - padding: 1.5rem; border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + padding: 1.5rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); text-align: center; - transition: transform 0.2s ease; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; } .card:hover { transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.card.failed-tasks { + border-left: 4px solid #f44336; + background: linear-gradient(135deg, #fff 0%, #ffebee 100%); +} + +.card.failed-tasks:hover { + background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%); } .card h3 { @@ -148,13 +159,22 @@ body { display: flex; justify-content: space-between; align-items: center; - padding: 1.5rem; - border-bottom: 1px solid #eee; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 2px solid #e0e0e0; } .section-header h2 { + margin: 0; color: #333; - font-size: 1.25rem; + font-size: 1.5rem; +} + +.section-description { + margin: 0.5rem 0 0 0; + color: #666; + font-size: 0.9rem; + font-style: italic; } .filter-controls select { @@ -215,29 +235,160 @@ body { gap: 0.5rem; } +/* Task status badges */ .status-badge { - padding: 0.25rem 0.5rem; + padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.75rem; - font-weight: 500; + font-weight: 600; text-transform: uppercase; + letter-spacing: 0.5px; } .status-active { - background-color: #e3f2fd; - color: #1976d2; -} - -.status-completed { - background-color: #e8f5e8; - color: #2e7d32; + background-color: #4caf50; + color: white; } .status-expired { - background-color: #fce4ec; - color: #c2185b; + background-color: #ff9800; + color: white; } +.status-failed { + background-color: #f44336; + color: white; +} + +/* CRS status indicators */ +.crs-error-info, +.crs-success-info, +.crs-pending-info { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.crs-error-info { + background-color: #ffebee; + color: #c62828; + border: 1px solid #ffcdd2; +} + +.crs-success-info { + background-color: #e8f5e8; + color: #2e7d32; + border: 1px solid #c8e6c9; +} + +.crs-pending-info { + background-color: #fff3e0; + color: #ef6c00; + border: 1px solid #ffcc02; +} + +.crs-error-icon, +.crs-success-icon, +.crs-pending-icon { + font-size: 1rem; +} + +/* Failed task styling */ +.task-failed { + border-left: 4px solid #f44336; + background-color: #ffebee; +} + +.task-failed:hover { + background-color: #ffcdd2; +} + +/* CRS error details */ +.crs-error-details { + margin-top: 0.5rem; + padding: 0.5rem; + background-color: #fff3e0; + border: 1px solid #ffcc02; + border-radius: 4px; + font-size: 0.8rem; + color: #e65100; +} + +.crs-error-details strong { + color: #bf360c; +} + +/* Notification system */ +.notification { + position: fixed; + top: 20px; + right: 20px; + padding: 1rem 1.5rem; + border-radius: 4px; + color: white; + font-weight: 500; + z-index: 2000; + animation: slideIn 0.3s ease; + max-width: 400px; + word-wrap: break-word; +} + +.notification-success { + background-color: #4caf50; +} + +.notification-error { + background-color: #f44336; +} + +.notification-warning { + background-color: #ff9800; +} + +.notification-info { + background-color: #2196f3; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Error message styling for backend errors */ +.error-message { + color: #D8000C !important; + background-color: #FFBABA !important; + border: 1px solid #D8000C !important; + padding: 10px !important; + margin: 10px 0 !important; + border-radius: 5px !important; + display: flex !important; + align-items: center !important; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important; + font-size: 14px !important; + line-height: 1.4 !important; + box-shadow: 0 2px 8px rgba(216, 0, 12, 0.15) !important; +} + +.error-message::before { + content: '⚠' !important; + font-size: 20px !important; + margin-right: 10px !important; + flex-shrink: 0 !important; +} + +/* Make dashboard stats clickable */ .task-stats { display: flex; gap: 1rem; @@ -483,14 +634,20 @@ form { /* No data states */ .no-data { text-align: center; - padding: 2rem; + padding: 3rem 2rem; color: #666; } .no-data-icon { font-size: 3rem; margin-bottom: 1rem; - opacity: 0.3; + opacity: 0.6; +} + +.no-data-subtitle { + margin-top: 0.5rem; + font-size: 0.9rem; + color: #888; } /* Artifacts container */ @@ -609,15 +766,4 @@ form { border-radius: 4px; max-height: 200px; overflow-y: auto; -} - -/* Make dashboard stats clickable */ -.status-card { - cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.status-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0,0,0,0.15); } \ No newline at end of file diff --git a/orchestrator/test/test_crs_client.py b/orchestrator/test/test_crs_client.py index a489c132..0271dd31 100644 --- a/orchestrator/test/test_crs_client.py +++ b/orchestrator/test/test_crs_client.py @@ -55,7 +55,7 @@ class TestCRSClient: result = crs_client.submit_task(sample_task) - assert result is True + assert result.success is True # Verify request was made correctly mock_post.assert_called_once_with( @@ -76,7 +76,7 @@ class TestCRSClient: result = crs_client_no_auth.submit_task(sample_task) - assert result is True + assert result.success is True # Verify request was made without auth mock_post.assert_called_once_with( @@ -98,7 +98,7 @@ class TestCRSClient: result = crs_client.submit_task(sample_task) - assert result is False + assert result.success is False @patch("requests.post") def test_submit_task_exception(self, mock_post, crs_client, sample_task): @@ -108,7 +108,7 @@ class TestCRSClient: result = crs_client.submit_task(sample_task) - assert result is False + assert result.success is False @patch("requests.get") def test_ping_success_ready(self, mock_get, crs_client):