Add benchmark dashboard - #169
Conversation
- Import plot_reports.py from d_princer_micro_benchmarking branch. - Update plot_reports.py to support both 6-field and 7-field CSV test matrix parameters. - Add --no-auto-plot and --plot-metric-group flags to orchestrator.py and run.sh. - Add pandas and matplotlib dependencies to requirements.txt. - Explicitly disable plotting in kokoro_run.sh using --no-auto-plot.
Add a Python FastAPI server and Single-Page HTML/Tailwind/JS frontend under benchmark-dashboard/. The dashboard supports: - User metadata (username and run description tracking). - Dynamic test parameters configurator (automatically listing files from test_suites/). - Resource-locked queue management serializing runs per target machine while running different targets concurrently. - Expanding historical runs to view configurations and output logs. - Cloning run parameters. - Rendering comparative performance charts using Chart.js based on BigQuery run metrics.
- Updated Username field to Username (LDAP) for team tracking. - Added advanced Managed Instance Group settings: single/multi-thread GCE VM templates support. - Added '+ Add Custom CSV' endpoints and modals for test cases and mount options. - Implemented real-time dynamic configurations previewer for selected test suites, mount config CSVs, and FIO templates. - Defaulted artifacts bucket to 'pranjal-bucket-1'.
…Split Views, and Progress Analysis - Restructured dashboard UI to use a Google-style clean Light Mode theme. - Added Google LDAP sign-in overlay card saving session identity locally. - Restructured layout to a side-by-side split screen showing the configuration form and live previews concurrently. - Re-routed preview accordion widgets to render as direct view block inspectors. - Added input fields for custom GCS artifacts and test data buckets. - Implemented real-time worker-node execution progress tracker matching the progress analysis logic of 'analyse.py'. - Added SSH project network requirement banner and documentation cards.
- Renamed UI-created FIO folder target path to 'test_suites/custom_fio_configs' for naming consistency. - Updated root .gitignore to ignore local database caches, virtual environments, and custom benchmark templates.
- Added Pytest unit tests in tests/test_api.py covering configurations listing, traversal security guards, run submissions, and progress tracking. - Fixed an AttributeError by adding the missing commit_hash parameter to the BenchmarkRunRequest Pydantic model. - Fixed a SQLite UNIQUE constraint collision bug by appending a random integer suffix to enqueued benchmark IDs.
- Implemented GCE lookup resolver in main.py to dynamically locate VM projects and zones. - Locked Project input field on the SPA UI as a read-only showcase field auto-populated by the VM resolution loop. - Added VM zone auto-fill based on search resolver output. - Re-aligned BQ comparator queries to dynamically read project IDs, enabling cross-project plots.
There was a problem hiding this comment.
Code Review
This pull request introduces a web-based benchmark dashboard for GCSFuse, adding a FastAPI backend, a frontend SPA, and integrating it with the existing distributed micro-benchmark orchestrator. Key security and code quality feedback highlights high-severity vulnerabilities, including path traversal in file previews, stored XSS in frontend rendering, and SQL injection in BigQuery queries. Additionally, improvements are suggested to handle subprocess termination on task cancellation, utilize the native GCS client library instead of blocking shell commands, and fix a hardcoded project ID in the BigQuery history endpoint.
| safe_path = (DMB_DIR / path).resolve() | ||
| if not str(safe_path).startswith(str(DMB_DIR)): | ||
| raise HTTPException(status_code=403, detail="Access denied: Path lies outside benchmark directory") |
There was a problem hiding this comment.
The current path validation uses startswith on the resolved path string. This is vulnerable to path traversal if there are sibling directories with similar prefixes (e.g., /home/user/gcs and /home/user/gcs_other). Using pathlib.Path.relative_to is a much more robust and secure way to ensure the path is strictly inside the target directory.
| safe_path = (DMB_DIR / path).resolve() | |
| if not str(safe_path).startswith(str(DMB_DIR)): | |
| raise HTTPException(status_code=403, detail="Access denied: Path lies outside benchmark directory") | |
| try: | |
| safe_path = (DMB_DIR / path).resolve() | |
| safe_path.relative_to(DMB_DIR.resolve()) | |
| except ValueError: | |
| raise HTTPException(status_code=403, detail="Access denied: Path lies outside benchmark directory") |
| item.innerHTML = ` | ||
| <div class="flex justify-between items-start mb-2"> | ||
| <span class="font-mono text-xs text-slate-500 font-bold">${run.benchmark_id}</span> | ||
| <span class="text-[10px] font-bold px-2 py-0.5 rounded border uppercase tracking-wider ${statusColors[run.status] || 'bg-slate-100'}">${run.status}</span> | ||
| </div> | ||
| <h4 class="font-bold text-sm text-slate-800 mb-1 truncate">${run.description}</h4> | ||
| <p class="text-xs text-slate-500 mb-2">VM: <span class="font-mono font-bold text-slate-700">${run.executor_vm}</span></p> | ||
| <div class="flex justify-between items-center text-[10px] text-slate-400"> | ||
| <span>User: ${run.username}</span> | ||
| <button onclick="cancelRun(event, '${run.benchmark_id}')" class="text-red-600 hover:text-red-800 font-bold uppercase transition"><i class="fa-solid fa-ban mr-1"></i>Cancel</button> | ||
| </div> | ||
| `; |
There was a problem hiding this comment.
User-controlled fields like run.description and run.username are rendered directly into the DOM using innerHTML without escaping. This introduces a Stored Cross-Site Scripting (XSS) vulnerability. All user-supplied values should be escaped before being rendered in HTML.
const escapeHtml = (str) => {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
};
item.innerHTML = `
<div class="flex justify-between items-start mb-2">
<span class="font-mono text-xs text-slate-500 font-bold">${escapeHtml(run.benchmark_id)}</span>
<span class="text-[10px] font-bold px-2 py-0.5 rounded border uppercase tracking-wider ${statusColors[run.status] || 'bg-slate-100'}">${escapeHtml(run.status)}</span>
</div>
<h4 class="font-bold text-sm text-slate-800 mb-1 truncate">${escapeHtml(run.description)}</h4>
<p class="text-xs text-slate-500 mb-2">VM: <span class="font-mono font-bold text-slate-700">${escapeHtml(run.executor_vm)}</span></p>
<div class="flex justify-between items-center text-[10px] text-slate-400">
<span>User: ${escapeHtml(run.username)}</span>
<button onclick="cancelRun(event, '${escapeHtml(run.benchmark_id)}')" class="text-red-600 hover:text-red-800 font-bold uppercase transition"><i class="fa-solid fa-ban mr-1"></i>Cancel</button>
</div>
`;| try: | ||
| # Open local log file to stream subprocess output | ||
| with open(log_file_path, "w") as log_f: | ||
| process = await asyncio.create_subprocess_exec( | ||
| "python3", *args, | ||
| cwd=str(DMB_DIR), | ||
| stdout=log_f, | ||
| stderr=subprocess.STDOUT if hasattr(subprocess, 'STDOUT') else log_f | ||
| ) | ||
|
|
||
| # Wait for execution to finish | ||
| exit_code = await process.wait() | ||
|
|
||
| if exit_code == 0: | ||
| logger.info(f"Subprocess finished successfully for {benchmark_id}") | ||
| db.update_run_status(benchmark_id, "completed", completed_at=datetime.utcnow().isoformat()) | ||
| else: | ||
| logger.error(f"Subprocess failed with exit code {exit_code} for {benchmark_id}") | ||
| db.update_run_status(benchmark_id, "failed", completed_at=datetime.utcnow().isoformat()) | ||
|
|
There was a problem hiding this comment.
If the execute_orchestrator task is cancelled (e.g., during shutdown or manual cancellation), the await process.wait() call will raise asyncio.CancelledError. However, the spawned subprocess will continue running in the background as an orphaned process. Wrapping the subprocess wait in a try...except asyncio.CancelledError block ensures that the subprocess is properly terminated when the task is cancelled.
process = None
try:
# Open local log file to stream subprocess output
with open(log_file_path, "w") as log_f:
process = await asyncio.create_subprocess_exec(
"python3", *args,
cwd=str(DMB_DIR),
stdout=log_f,
stderr=subprocess.STDOUT if hasattr(subprocess, 'STDOUT') else log_f
)
# Wait for execution to finish
exit_code = await process.wait()
if exit_code == 0:
logger.info(f"Subprocess finished successfully for {benchmark_id}")
db.update_run_status(benchmark_id, "completed", completed_at=datetime.utcnow().isoformat())
else:
logger.error(f"Subprocess failed with exit code {exit_code} for {benchmark_id}")
db.update_run_status(benchmark_id, "failed", completed_at=datetime.utcnow().isoformat())
except asyncio.CancelledError:
if process and process.returncode is None:
logger.warning(f"Orchestrator process for {benchmark_id} was cancelled. Terminating subprocess...")
try:
process.terminate()
await process.wait()
except Exception as e:
logger.error(f"Failed to terminate subprocess: {e}")
raise| # Resolve GCE project dynamically from DB, fallback to default | ||
| run_config = db.get_run(rid) | ||
| proj = run_config.get("project", project_id) if run_config else project_id |
There was a problem hiding this comment.
The proj variable is retrieved from run_config (which contains user-supplied values stored in the SQLite database) and is directly interpolated into the BigQuery SQL query string. This introduces a SQL injection vulnerability. Validating the project ID against a strict regular expression before query execution is highly recommended.
| # Resolve GCE project dynamically from DB, fallback to default | |
| run_config = db.get_run(rid) | |
| proj = run_config.get("project", project_id) if run_config else project_id | |
| import re | |
| run_config = db.get_run(rid) | |
| proj = run_config.get("project", project_id) if run_config else project_id | |
| if not re.match(r"^[a-z0-9\-]{6,30}$", proj): | |
| raise HTTPException(status_code=400, detail="Invalid project ID format") |
| if run["status"] == "running": | ||
| # Create cancel flag in GCS for workers to detect | ||
| try: | ||
| import subprocess | ||
| cancel_path = f"gs://{run['artifacts_bucket']}/{run_id}/cancel" | ||
| subprocess.run(['gcloud', 'storage', 'cp', '-', cancel_path], input=b'cancelled', check=True) | ||
| logger.info(f"GCS cancellation flag written for {run_id}") | ||
| except Exception as e: | ||
| logger.error(f"Failed to create cancel flag in GCS: {e}") |
There was a problem hiding this comment.
Spawning a synchronous gcloud storage cp subprocess to write a cancellation flag to GCS is inefficient and blocks the threadpool. Since the google-cloud-storage library is already imported and used in this file, you should use the native GCS client library instead.
| if run["status"] == "running": | |
| # Create cancel flag in GCS for workers to detect | |
| try: | |
| import subprocess | |
| cancel_path = f"gs://{run['artifacts_bucket']}/{run_id}/cancel" | |
| subprocess.run(['gcloud', 'storage', 'cp', '-', cancel_path], input=b'cancelled', check=True) | |
| logger.info(f"GCS cancellation flag written for {run_id}") | |
| except Exception as e: | |
| logger.error(f"Failed to create cancel flag in GCS: {e}") | |
| if run["status"] == "running": | |
| # Create cancel flag in GCS for workers to detect | |
| try: | |
| client = storage.Client() | |
| bucket = client.bucket(run['artifacts_bucket']) | |
| blob = bucket.blob(f"{run_id}/cancel") | |
| blob.upload_from_string("cancelled") | |
| logger.info(f"GCS cancellation flag written for {run_id}") | |
| except Exception as e: | |
| logger.error(f"Failed to create cancel flag in GCS: {e}") |
| query = """ | ||
| SELECT DISTINCT benchmark_id, run_timestamp, commit, mount_args, io_type | ||
| FROM `gcs-fuse-test-ml.periodic_benchmarks.kokoro_run_*` | ||
| UNION DISTINCT | ||
| SELECT DISTINCT benchmark_id, run_timestamp, commit, mount_args, io_type | ||
| FROM `gcs-fuse-test-ml.adhoc_benchmarks.local_run_*` | ||
| ORDER BY run_timestamp DESC | ||
| LIMIT 100 | ||
| """ |
There was a problem hiding this comment.
The get_bq_history endpoint accepts a project_id parameter but hardcodes gcs-fuse-test-ml in the SQL query, rendering the parameter useless. The query should safely interpolate the validated project_id parameter.
| query = """ | |
| SELECT DISTINCT benchmark_id, run_timestamp, commit, mount_args, io_type | |
| FROM `gcs-fuse-test-ml.periodic_benchmarks.kokoro_run_*` | |
| UNION DISTINCT | |
| SELECT DISTINCT benchmark_id, run_timestamp, commit, mount_args, io_type | |
| FROM `gcs-fuse-test-ml.adhoc_benchmarks.local_run_*` | |
| ORDER BY run_timestamp DESC | |
| LIMIT 100 | |
| """ | |
| import re | |
| if not re.match(r"^[a-z0-9\-]{6,30}$", project_id): | |
| raise HTTPException(status_code=400, detail="Invalid project ID format") | |
| query = f""" | |
| SELECT DISTINCT benchmark_id, run_timestamp, commit, mount_args, io_type | |
| FROM `{project_id}.periodic_benchmarks.kokoro_run_*` | |
| UNION DISTINCT | |
| SELECT DISTINCT benchmark_id, run_timestamp, commit, mount_args, io_type | |
| FROM `{project_id}.adhoc_benchmarks.local_run_*` | |
| ORDER BY run_timestamp DESC | |
| LIMIT 100 | |
| """ |
- Disabled the Zone input field by default and styled it read-only on startup. - Configured frontend GCE VM resolver to auto-fill the target Zone and keep the field locked when successfully resolved. - Implemented automatic fallback to unlock the Zone field for manual entry if GCE name discovery yields no zone context.
- Increased max-height on preview code pre blocks from max-h-36 (144px) to max-h-72 (288px) to reduce vertical scrolling when reviewing large test matrix CSV files.
- Added python -u flag to the create_subprocess_exec launcher to disable stdout buffering when output is redirected to log files. - Ensures benchmark log outputs are immediately flushed to orchestrator.log and streamed to the UI in real-time.
…g console to full width
…ve dropdown selections during template addition
…and enable dynamic read/write labeling
…file name collisions
… via systemd service
…sword, and contribution footer
…and remove bucket-cat.txt from tracking
…l test logic for a simpler, faster dashboard
…rows for a clean UI
…d fix smashed graphs
…ink auth token validation
… comparison data authentication
…ium full-screen chart lightbox zoom modal
…render full dynamic configs CSV mount options table in PDF report
…revent horizontal overlap
…izontal column overflow
…rt Mount Options table
…orm/gcsfuse-tools
…mma-separated FIO parameters format with format description labels
5181129 to
3de4261
Compare
aaff610 to
8c1f797
Compare
…ropagation and expand UI layout sizes
No description provided.