From e0c0da6deff23b58ad7cb5acdeb5229259cd7467 Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Tue, 29 Sep 2026 15:57:06 +0400 Subject: [PATCH] feat(backend,core): lists available benchmarking tools via API --- src/disk_checker/core/tool_scanner.py | 57 ++++++++ src/disk_checker/web/models.py | 45 +------ src/disk_checker/web/routes.py | 187 +++----------------------- src/disk_checker/web/server.py | 11 +- 4 files changed, 90 insertions(+), 210 deletions(-) create mode 100644 src/disk_checker/core/tool_scanner.py diff --git a/src/disk_checker/core/tool_scanner.py b/src/disk_checker/core/tool_scanner.py new file mode 100644 index 0000000..ae0875f --- /dev/null +++ b/src/disk_checker/core/tool_scanner.py @@ -0,0 +1,57 @@ +"""Tool detection module for disk-checker.""" + +import shutil +from typing import Dict, List +from dataclasses import dataclass + + +@dataclass +class ToolInfo: + """Information about a benchmarking tool.""" + name: str + available: bool + version: str = "" + + +# Tools to scan for +TOOLS_TO_SCAN = [ + "fio", + "badblocks", + "smartctl", + "iozone", + "hdparm", +] + + +def scan_tools() -> Dict[str, ToolInfo]: + """ + Scan host system for available benchmarking tools. + + Returns: + Dictionary mapping tool names to ToolInfo objects + """ + results = {} + for tool_name in TOOLS_TO_SCAN: + available = shutil.which(tool_name) is not None + version = "" + if available: + # Try to get version (optional enhancement) + try: + import subprocess + result = subprocess.run( + [tool_name, "--version"], + capture_output=True, + text=True, + timeout=2 + ) + version = result.stdout.split("\n")[0][:50] + except Exception: + # If we can't even run '--version', then + # a tool is not considered as available + available = False + results[tool_name] = ToolInfo( + name=tool_name, + available=available, + version=version + ) + return results \ No newline at end of file diff --git a/src/disk_checker/web/models.py b/src/disk_checker/web/models.py index 26b27bc..adb9621 100644 --- a/src/disk_checker/web/models.py +++ b/src/disk_checker/web/models.py @@ -20,38 +20,6 @@ class JobStatus(str, Enum): FAILED = "failed" -class MetricInfo(BaseModel): - """Information about a metric.""" - name: str - total_ios: int - min_latency_ns: Optional[float] = None - max_latency_ns: Optional[float] = None - avg_latency_ns: Optional[float] = None - - -class PercentileInfo(BaseModel): - """Latency value at a specific percentile.""" - percentile: float - latency_ns: float - latency_display: str - - -class MetricResult(BaseModel): - """Complete result for a metric.""" - metric: str - total_ios: int - percentiles: List[PercentileInfo] - min_latency_ns: float - max_latency_ns: float - avg_latency_ns: float - - -class ScanResponse(BaseModel): - """Response from CSV scan endpoint.""" - metrics: List[MetricResult] - summary: Dict[str, Any] - - class BenchmarkRequest(BaseModel): """Request to start a benchmark job.""" device: str = Field(..., description="Block device path (e.g., /dev/sda)") @@ -81,15 +49,4 @@ class JobStatusResponse(BaseModel): output_path: Optional[str] = None error: Optional[str] = None created_at: Optional[str] = None - completed_at: Optional[str] = None - - -class PlotRequest(BaseModel): - """Request to generate a plot.""" - metric: Optional[str] = None - unit: str = Field(default="us", pattern="^(ns|us|ms|s)$") - log_x: bool = False - log_y: bool = False - cdf: bool = False - normalize: bool = False - title: Optional[str] = None \ No newline at end of file + completed_at: Optional[str] = None \ No newline at end of file diff --git a/src/disk_checker/web/routes.py b/src/disk_checker/web/routes.py index 27e0642..e8da937 100644 --- a/src/disk_checker/web/routes.py +++ b/src/disk_checker/web/routes.py @@ -3,7 +3,7 @@ import asyncio import tempfile from pathlib import Path -from typing import List, Optional +from typing import List, Optional, Dict from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, Request from fastapi.responses import JSONResponse, FileResponse @@ -11,177 +11,18 @@ import pandas as pd from disk_checker.core.extractor import load_latency_csv from disk_checker.core.helpers import percentile_table +from disk_checker.core.tool_scanner import ToolInfo + + from disk_checker.plotting.plots import generate_plot from disk_checker.web.models import ( - ScanResponse, - BenchmarkRequest, JobStatusResponse, - PlotRequest, - MetricInfo, - PercentileInfo, - MetricResult, ) + router = APIRouter() -def _calculate_metric_results(df: pd.DataFrame, metric: str, pcts: List[float]) -> MetricResult: - """Calculate detailed results for a metric.""" - metric_df = df[df["metric"] == metric] - counts = metric_df["count"].values.astype(float) - total_ios = int(counts.sum()) - - latencies_ns = metric_df["latency_ns"].values - - # Calculate percentiles - percentiles = [] - cdf = __import__('numpy').cumsum(counts) / total_ios * 100.0 - for p in pcts: - idx = min(__import__('numpy').searchsorted(cdf, p), len(latencies_ns) - 1) - percentiles.append(PercentileInfo( - percentile=p, - latency_ns=float(latencies_ns[idx]), - latency_display=f"{latencies_ns[idx]:.3f} ns" - )) - - return MetricResult( - metric=metric, - total_ios=total_ios, - percentiles=percentiles, - min_latency_ns=float(latencies_ns.min()), - max_latency_ns=float(latencies_ns.max()), - avg_latency_ns=float(latencies_ns.mean()), - ) - - -@router.post("/scan", response_model=ScanResponse) -async def scan_csv(file: UploadFile = File(...)): - """ - Upload a fio JSON+ CSV and return analysis. - """ - with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp: - content = await file.read() - tmp.write(content) - tmp_path = tmp.name - - try: - df = load_latency_csv(tmp_path) - if df.empty: - raise HTTPException(status_code=400, detail="No valid data in CSV") - - # Calculate metrics - metrics = [] - pcts = [50, 90, 99, 99.9, 99.99] - for metric in sorted(df["metric"].unique()): - metrics.append(_calculate_metric_results(df, metric, pcts)) - - summary = { - "total_metrics": len(metrics), - "total_ios": sum(m.total_ios for m in metrics), - } - - return ScanResponse(metrics=metrics, summary=summary) - finally: - Path(tmp_path).unlink(missing_ok=True) - - -@router.post("/plot") -async def generate_plot_endpoint( - file: UploadFile = File(...), - metric: Optional[str] = None, - unit: str = "us", - log_x: bool = False, - log_y: bool = False, - cdf: bool = False, - normalize: bool = False, - title: Optional[str] = None, -): - """ - Generate and return a latency plot. - """ - with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp: - content = await file.read() - tmp.write(content) - tmp_path = tmp.name - - try: - df = load_latency_csv(tmp_path) - if df.empty: - raise HTTPException(status_code=400, detail="No valid data in CSV") - - # Determine metrics - available = sorted(df["metric"].unique()) - if metric: - if metric not in available: - raise HTTPException( - status_code=400, - detail=f"Metric {metric} not found" - ) - metrics = [metric] - else: - totals = df.groupby("metric")["count"].sum().sort_values(ascending=False) - metrics = [totals.index[0]] - - # Generate plot - with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as img: - output_path = Path(img.name) - - generate_plot( - df=df, - metrics=metrics, - output_path=output_path, - unit=unit, - log_x=log_x, - log_y=log_y, - normalize=normalize, - show_cdf=cdf, - title=title or "", - ) - - return FileResponse(output_path, media_type="image/png") - finally: - Path(tmp_path).unlink(missing_ok=True) - - -@router.post("/benchmark", response_model=JobStatusResponse) -async def start_benchmark(request: BenchmarkRequest): - """ - Start a disk benchmark job. - """ - orchestrator = request.app.state.orchestrator - - job_id = f"bench_{asyncio.get_event_loop().time()}" - - async def run_job(): - try: - if request.tool == "fio": - output_path = await orchestrator.run_fio( - device=request.device, - **request.options, - ) - elif request.tool == "badblocks": - output_path = await orchestrator.run_badblocks( - device=request.device, - **request.options, - ) - else: - raise ValueError(f"Unknown tool: {request.tool}") - return { - "job_id": job_id, - "status": "completed", - "output_path": str(output_path), - } - except Exception as e: - return {"job_id": job_id, "status": "failed", "error": str(e)} - - request.app.state.jobs[job_id] = { - "status": "running", - "task": asyncio.create_task(run_job()), - } - - return JobStatusResponse(job_id=job_id, status="running") - - @router.get("/jobs/{job_id}", response_model=JobStatusResponse) async def get_job_status(job_id: str, request: Request): """ @@ -206,4 +47,20 @@ async def list_jobs(request: Request): """ List all benchmark jobs. """ - return list(request.app.state.jobs.keys()) \ No newline at end of file + return list(request.app.state.jobs.keys()) + + +@router.get("/tools") +async def get_available_tools(request: Request): + """ + Get list of available benchmarking tools. + """ + tools = request.app.state.available_tools + return { + tool_name: { + "name" : tool.name, + "available": tool.available, + "version" : tool.version + } + for tool_name, tool in tools.items() + } \ No newline at end of file diff --git a/src/disk_checker/web/server.py b/src/disk_checker/web/server.py index b5c669a..05841c7 100644 --- a/src/disk_checker/web/server.py +++ b/src/disk_checker/web/server.py @@ -1,11 +1,14 @@ """FastAPI application for disk-checker web API.""" +import asyncio + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager import logging from disk_checker.core.orchestrator import Orchestrator +from disk_checker.core.tool_scanner import scan_tools logger = logging.getLogger(__name__) @@ -14,9 +17,15 @@ logger = logging.getLogger(__name__) async def lifespan(app: FastAPI): """Application lifespan handler.""" # Startup: initialize orchestrator - app.state.orchestrator = Orchestrator(work_dir=app.state.work_dir) + #app.state.orchestrator = Orchestrator(work_dir=app.state.work_dir) app.state.jobs = {} + + # Scan for available benchmarking tools + app.state.available_tools = await asyncio.to_thread(scan_tools) + logger.info(f"Available tools: {list(app.state.available_tools.keys())}") + logger.info("Disk Checker API started") + yield # Shutdown: cleanup logger.info("Disk Checker API stopped")