Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import html | |
| import json | |
| import mimetypes | |
| import time | |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| from pathlib import Path | |
| from urllib.parse import parse_qs, quote, unquote, urlparse | |
| WORKSPACE_ROOT = Path(__file__).resolve().parents[1] | |
| DEFAULT_SCAN_ROOTS = [ | |
| WORKSPACE_ROOT / "output", | |
| WORKSPACE_ROOT / "shared_output" / "personal" / "liduan", | |
| ] | |
| RUN_MARKERS = ( | |
| "comparison_summary.json", | |
| "comparison.jsonl", | |
| "manifest.jsonl", | |
| "batch_report.json", | |
| "experiment_summary.json", | |
| "summary.json", | |
| "preview.html", | |
| "run_config.json", | |
| ) | |
| IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"} | |
| TEXT_SUFFIXES = {".json", ".jsonl", ".csv", ".txt", ".log"} | |
| MAX_JSONL_ITEMS = 3000 | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser(description="Serve a browser UI for generated ChartPipeline outputs.") | |
| parser.add_argument("--host", default="0.0.0.0", help="Bind host.") | |
| parser.add_argument("--port", type=int, default=8787, help="Bind port.") | |
| parser.add_argument( | |
| "--scan-root", | |
| action="append", | |
| type=Path, | |
| help="Directory to scan for output runs. Can be passed multiple times.", | |
| ) | |
| parser.add_argument("--max-depth", type=int, default=3, help="Directory scan depth below each scan root.") | |
| return parser.parse_args() | |
| def safe_resolve(path: Path) -> Path: | |
| return path.expanduser().resolve() | |
| def read_json(path: Path) -> dict: | |
| try: | |
| data = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError): | |
| return {} | |
| return data if isinstance(data, dict) else {} | |
| def read_jsonl(path: Path, *, limit: int = MAX_JSONL_ITEMS) -> tuple[list[dict], int, bool]: | |
| rows: list[dict] = [] | |
| total = 0 | |
| truncated = False | |
| try: | |
| with path.open("r", encoding="utf-8") as handle: | |
| for line in handle: | |
| if not line.strip(): | |
| continue | |
| total += 1 | |
| if len(rows) >= limit: | |
| truncated = True | |
| continue | |
| try: | |
| payload = json.loads(line) | |
| except json.JSONDecodeError: | |
| continue | |
| if isinstance(payload, dict): | |
| rows.append(payload) | |
| except OSError: | |
| return [], 0, False | |
| return rows, total, truncated | |
| def path_mtime_ns(path: Path) -> int: | |
| try: | |
| return path.stat().st_mtime_ns | |
| except OSError: | |
| return 0 | |
| def path_size(path: Path) -> int: | |
| try: | |
| return path.stat().st_size | |
| except OSError: | |
| return 0 | |
| def iter_dirs(root: Path, max_depth: int) -> list[Path]: | |
| dirs: list[Path] = [] | |
| stack: list[tuple[Path, int]] = [(root, 0)] | |
| while stack: | |
| current, depth = stack.pop() | |
| dirs.append(current) | |
| if depth >= max_depth: | |
| continue | |
| try: | |
| children = list(current.iterdir()) | |
| except OSError: | |
| continue | |
| for child in children: | |
| if child.name.startswith(".") or child.is_symlink(): | |
| continue | |
| try: | |
| if child.is_dir(): | |
| stack.append((child, depth + 1)) | |
| except OSError: | |
| continue | |
| return dirs | |
| def discover_run_dirs(root: Path, max_depth: int) -> list[Path]: | |
| runs: list[Path] = [] | |
| stack: list[tuple[Path, int]] = [(root, 0)] | |
| while stack: | |
| current, depth = stack.pop() | |
| if looks_like_run(current): | |
| runs.append(current) | |
| continue | |
| if depth >= max_depth: | |
| continue | |
| try: | |
| children = list(current.iterdir()) | |
| except OSError: | |
| continue | |
| for child in children: | |
| if child.name.startswith(".") or child.is_symlink(): | |
| continue | |
| try: | |
| if child.is_dir(): | |
| stack.append((child, depth + 1)) | |
| except OSError: | |
| continue | |
| return runs | |
| def looks_like_run(path: Path) -> bool: | |
| if any((path / marker).is_file() for marker in RUN_MARKERS): | |
| return True | |
| try: | |
| return any(child.is_dir() and (child / "report.json").is_file() for child in path.iterdir()) | |
| except OSError: | |
| return False | |
| def is_under_seen_root(path: Path, seen: set[Path]) -> bool: | |
| for root in seen: | |
| try: | |
| path.relative_to(root) | |
| return True | |
| except ValueError: | |
| continue | |
| return False | |
| def run_mtime_ns(path: Path) -> int: | |
| latest = path_mtime_ns(path) | |
| for marker in RUN_MARKERS: | |
| latest = max(latest, path_mtime_ns(path / marker)) | |
| try: | |
| children = list(path.iterdir()) | |
| except OSError: | |
| return latest | |
| for child in children[:250]: | |
| latest = max(latest, path_mtime_ns(child)) | |
| if child.is_dir(): | |
| latest = max( | |
| latest, | |
| path_mtime_ns(child / "report.json"), | |
| path_mtime_ns(child / "task_status.json"), | |
| ) | |
| return latest | |
| def classify_run(root: Path) -> str: | |
| if (root / "comparison.jsonl").is_file() or (root / "comparison_summary.json").is_file(): | |
| return "comparison" | |
| if (root / "manifest.jsonl").is_file(): | |
| return "manifest" | |
| if (root / "experiment_summary.json").is_file(): | |
| return "fci-experiment" | |
| if (root / "batch_report.json").is_file() or any(root.glob("*/report.json")): | |
| return "fci-batch" | |
| if (root / "preview.html").is_file(): | |
| return "preview" | |
| return "run" | |
| class InspectorState: | |
| def __init__(self, scan_roots: list[Path], max_depth: int) -> None: | |
| self.scan_roots = [] | |
| seen_roots: set[Path] = set() | |
| for root in scan_roots: | |
| resolved = safe_resolve(root) | |
| if resolved in seen_roots: | |
| continue | |
| seen_roots.add(resolved) | |
| self.scan_roots.append(resolved) | |
| self.max_depth = max_depth | |
| self._runs_cache: list[Path] = [] | |
| self._runs_cache_until = 0.0 | |
| def allowed_roots(self) -> list[Path]: | |
| roots = [ | |
| *self.scan_roots, | |
| safe_resolve(WORKSPACE_ROOT / "output"), | |
| safe_resolve(WORKSPACE_ROOT / "shared_output"), | |
| ] | |
| unique: list[Path] = [] | |
| seen: set[Path] = set() | |
| for root in roots: | |
| if root in seen or not root.exists(): | |
| continue | |
| seen.add(root) | |
| unique.append(root) | |
| return unique | |
| def runs(self, *, force: bool = False) -> list[Path]: | |
| now = time.time() | |
| if not force and now < self._runs_cache_until: | |
| return list(self._runs_cache) | |
| found: list[Path] = [] | |
| seen: set[Path] = set() | |
| for scan_root in self.scan_roots: | |
| if not scan_root.is_dir(): | |
| continue | |
| for candidate in discover_run_dirs(scan_root, self.max_depth): | |
| resolved = safe_resolve(candidate) | |
| if resolved in seen or is_under_seen_root(resolved, seen): | |
| continue | |
| seen.add(resolved) | |
| found.append(resolved) | |
| found.sort(key=lambda path: (run_mtime_ns(path), str(path)), reverse=True) | |
| self._runs_cache = found | |
| self._runs_cache_until = now + 8.0 | |
| return found | |
| def resolve_run(self, raw_path: str) -> Path | None: | |
| if not raw_path: | |
| return None | |
| candidate = self.resolve_path(raw_path, require_file=False) | |
| if candidate is None or not candidate.is_dir(): | |
| return None | |
| for run in self.runs(): | |
| if candidate == run: | |
| return candidate | |
| return None | |
| def resolve_path(self, raw_path: str, *, require_file: bool = True) -> Path | None: | |
| raw_path = unquote(raw_path or "") | |
| if not raw_path: | |
| return None | |
| raw = Path(raw_path) | |
| candidates: list[Path] = [] | |
| if raw.is_absolute(): | |
| candidates.append(safe_resolve(raw)) | |
| else: | |
| candidates.append(safe_resolve(WORKSPACE_ROOT / raw)) | |
| for root in self.scan_roots: | |
| candidates.append(safe_resolve(root / raw)) | |
| for candidate in candidates: | |
| if require_file and not candidate.is_file(): | |
| continue | |
| if not require_file and not candidate.exists(): | |
| continue | |
| if self.is_allowed(candidate): | |
| return candidate | |
| return None | |
| def is_allowed(self, path: Path) -> bool: | |
| for root in self.allowed_roots: | |
| try: | |
| path.relative_to(root) | |
| return True | |
| except ValueError: | |
| continue | |
| return False | |
| def file_entry(self, label: str, raw_path: object) -> dict | None: | |
| if not raw_path: | |
| return None | |
| raw = str(raw_path) | |
| resolved = self.resolve_path(raw) | |
| suffix = Path(raw).suffix.lower() | |
| file_type = "image" if suffix in IMAGE_SUFFIXES else "html" if suffix == ".html" else "text" | |
| entry = { | |
| "label": label, | |
| "path": raw, | |
| "type": file_type, | |
| "exists": resolved is not None, | |
| "url": "", | |
| "size": 0, | |
| "version": "", | |
| } | |
| if resolved is not None: | |
| stat = resolved.stat() | |
| entry.update( | |
| { | |
| "path": str(resolved), | |
| "url": self.web_url(resolved), | |
| "size": stat.st_size, | |
| "version": f"{stat.st_mtime_ns}-{stat.st_size}", | |
| } | |
| ) | |
| return entry | |
| def web_url(self, path: Path) -> str: | |
| resolved = safe_resolve(path) | |
| return "/fs/" + quote(str(resolved).lstrip("/"), safe="/") | |
| def run_info(state: InspectorState, root: Path) -> dict: | |
| markers = [marker for marker in RUN_MARKERS if (root / marker).is_file()] | |
| kind = classify_run(root) | |
| summary = read_json(root / "comparison_summary.json") or read_json(root / "summary.json") | |
| config = read_json(root / "run_config.json") | |
| preview = state.file_entry("Preview", root / "preview.html") | |
| item_count = estimate_item_count(root, summary, kind) | |
| latest = run_mtime_ns(root) | |
| return { | |
| "id": str(root), | |
| "name": root.name, | |
| "path": str(root), | |
| "kind": kind, | |
| "markers": markers, | |
| "summary": compact_summary(summary), | |
| "config": compact_config(config), | |
| "item_count": item_count, | |
| "mtime_ns": latest, | |
| "updated": latest / 1_000_000_000 if latest else 0, | |
| "preview": preview if preview and preview.get("exists") else None, | |
| } | |
| def estimate_item_count(root: Path, summary: dict, kind: str) -> int: | |
| if isinstance(summary.get("total_samples"), int): | |
| return int(summary["total_samples"]) | |
| if kind == "comparison" and (root / "comparison.jsonl").is_file(): | |
| _, total, _ = read_jsonl(root / "comparison.jsonl", limit=0) | |
| return total | |
| if (root / "manifest.jsonl").is_file(): | |
| _, total, _ = read_jsonl(root / "manifest.jsonl", limit=0) | |
| return total | |
| return len(report_task_dirs(root)) | |
| def compact_summary(summary: dict) -> dict: | |
| if not summary: | |
| return {} | |
| keys = ( | |
| "total_samples", | |
| "total_templates", | |
| "status_counts", | |
| "fallback_counts", | |
| "empty_chart_counts", | |
| "png_diff_counts", | |
| ) | |
| return {key: summary[key] for key in keys if key in summary} | |
| def compact_config(config: dict) -> dict: | |
| if not config: | |
| return {} | |
| keys = ( | |
| "baseline_root", | |
| "output_root", | |
| "samples_per_template", | |
| "workers", | |
| "rerun_failures", | |
| "chart_only", | |
| "slot_polish_after_chart", | |
| "planned_slot_polish", | |
| "planned_slot_dry_run", | |
| "limit", | |
| ) | |
| return {key: config[key] for key in keys if key in config} | |
| def run_items(state: InspectorState, root: Path, *, limit: int = MAX_JSONL_ITEMS) -> dict: | |
| if (root / "comparison.jsonl").is_file(): | |
| rows, total, truncated = read_jsonl(root / "comparison.jsonl", limit=limit) | |
| items = [comparison_item(state, row, index) for index, row in enumerate(rows)] | |
| return {"source": "comparison.jsonl", "total": total, "truncated": truncated, "items": items} | |
| if (root / "manifest.jsonl").is_file(): | |
| rows, total, truncated = read_jsonl(root / "manifest.jsonl", limit=limit) | |
| items = [manifest_item(state, row, index) for index, row in enumerate(rows)] | |
| return {"source": "manifest.jsonl", "total": total, "truncated": truncated, "items": items} | |
| report_dirs = report_task_dirs(root) | |
| return { | |
| "source": "report.json", | |
| "total": len(report_dirs), | |
| "truncated": len(report_dirs) > limit, | |
| "items": [report_item(state, task_dir, index) for index, task_dir in enumerate(report_dirs[:limit])], | |
| } | |
| def report_task_dirs(root: Path) -> list[Path]: | |
| if (root / "experiment_summary.json").is_file(): | |
| dirs: list[Path] = [] | |
| try: | |
| variant_dirs = [path for path in root.iterdir() if path.is_dir()] | |
| except OSError: | |
| return [] | |
| for variant_dir in variant_dirs: | |
| try: | |
| dirs.extend(path for path in variant_dir.iterdir() if path.is_dir() and (path / "report.json").is_file()) | |
| except OSError: | |
| continue | |
| return sorted(dirs, key=lambda path: str(path)) | |
| try: | |
| return sorted( | |
| (path for path in root.iterdir() if path.is_dir() and (path / "report.json").is_file()), | |
| key=lambda path: str(path), | |
| ) | |
| except OSError: | |
| return [] | |
| def status_class(value: object) -> str: | |
| text = str(value).lower() | |
| if "regressed" in text or "failure" in text or text in {"false", "failed"}: | |
| return "bad" | |
| if "fixed" in text: | |
| return "warn" | |
| if "success" in text or text in {"true", "same_success", "passed"}: | |
| return "good" | |
| return "neutral" | |
| def comparison_item(state: InspectorState, row: dict, index: int) -> dict: | |
| status = row.get("status_change") | |
| if not status: | |
| status = "current_success" if row.get("current_success") else "current_failed" | |
| files = [ | |
| state.file_entry("Baseline final", row.get("baseline_final_png")), | |
| state.file_entry("Current final", row.get("current_final_png")), | |
| state.file_entry("Baseline chart", row.get("baseline_chart_svg")), | |
| state.file_entry("Current chart", row.get("current_chart_svg")), | |
| state.file_entry("Baseline SVG", row.get("baseline_final_svg")), | |
| state.file_entry("Current SVG", row.get("current_final_svg")), | |
| ] | |
| metrics = { | |
| "png_mae": row.get("png_mae"), | |
| "png_rmse": row.get("png_rmse"), | |
| "baseline_success": row.get("baseline_success"), | |
| "current_success": row.get("current_success"), | |
| "empty_chart_change": row.get("empty_chart_change"), | |
| "fallback_change": row.get("fallback_change"), | |
| "current_chart_visible": row.get("current_chart_visible"), | |
| "current_chart_text": row.get("current_chart_text"), | |
| "current_chart_shapes": row.get("current_chart_shapes"), | |
| } | |
| return { | |
| "id": f"comparison-{index}", | |
| "title": row.get("chart_name") or last_token(row.get("template_key")) or f"row {index}", | |
| "subtitle": f"{row.get('template_key', '-')}, sample {row.get('sample_index', '-')}", | |
| "status": status, | |
| "status_class": status_class(status), | |
| "metrics": metrics, | |
| "files": [file for file in files if file], | |
| "raw": row, | |
| } | |
| def manifest_item(state: InspectorState, row: dict, index: int) -> dict: | |
| status = "success" if row.get("success") or row.get("pipeline_success") else "failed" | |
| if row.get("planned_slot_polish"): | |
| status = f"{status} / planned_slot:{row.get('planned_slot_success')}" | |
| elif row.get("slot_polish_after_chart"): | |
| status = f"{status} / slot_polish:{row.get('slot_polish_success')}" | |
| files = [ | |
| state.file_entry("Final PNG", row.get("final_png")), | |
| state.file_entry("Planned polished PNG", row.get("planned_slot_polished_png")), | |
| state.file_entry("Slot polished PNG", row.get("slot_polished_png")), | |
| state.file_entry("Chart SVG", row.get("chart_svg")), | |
| state.file_entry("Final SVG", row.get("final_svg")), | |
| state.file_entry("Plan JSON", row.get("planned_slot_plan")), | |
| state.file_entry("Reference map", row.get("planned_slot_reference_map")), | |
| state.file_entry("Manifest", row.get("planned_slot_manifest") or row.get("slot_polish_manifest")), | |
| ] | |
| metrics = { | |
| "success": row.get("success"), | |
| "pipeline_success": row.get("pipeline_success"), | |
| "planned_slot_success": row.get("planned_slot_success"), | |
| "slot_polish_success": row.get("slot_polish_success"), | |
| "chart_empty": row.get("chart_empty"), | |
| "chart_visible": row.get("chart_visible"), | |
| "chart_text": row.get("chart_text"), | |
| "chart_shapes": row.get("chart_shapes"), | |
| "total_seconds": row.get("total_seconds"), | |
| } | |
| return { | |
| "id": f"manifest-{index}", | |
| "title": last_token(row.get("template_key")) or f"row {index}", | |
| "subtitle": f"{row.get('template_key', '-')}, sample {row.get('sample_index', '-')}", | |
| "status": status, | |
| "status_class": status_class(status), | |
| "metrics": metrics, | |
| "files": [file for file in files if file], | |
| "raw": row, | |
| } | |
| def report_item(state: InspectorState, task_dir: Path, index: int) -> dict: | |
| report = read_json(task_dir / "report.json") | |
| status = read_json(task_dir / "task_status.json") | |
| export = report.get("export", {}) | |
| best = report.get("best_candidate", {}) | |
| files = [ | |
| state.file_entry("Final composite", export.get("final_composite") or task_dir / "final_composite.png"), | |
| state.file_entry("Background", export.get("background_candidate") or task_dir / "background_candidate.png"), | |
| state.file_entry("Condition", task_dir / "condition_canvas.png"), | |
| state.file_entry("Rendered SVG", task_dir / "rendered_from_svg.png"), | |
| state.file_entry("Highlight difference", export.get("highlight_difference") or task_dir / "highlight_difference.png"), | |
| state.file_entry("Report", task_dir / "report.json"), | |
| ] | |
| state_text = status.get("state") or ("completed" if report else "unknown") | |
| return { | |
| "id": f"report-{index}", | |
| "title": report.get("task_id") or status.get("task_id") or task_dir.name, | |
| "subtitle": str(task_dir), | |
| "status": state_text, | |
| "status_class": status_class(state_text), | |
| "metrics": { | |
| "pipeline_mode": report.get("runtime", {}).get("pipeline_mode"), | |
| "variant_id": report.get("runtime", {}).get("variant_id"), | |
| "overall": best.get("score", {}).get("overall"), | |
| "elapsed_seconds": report.get("runtime", {}).get("elapsed_seconds") or status.get("elapsed_seconds"), | |
| }, | |
| "files": [file for file in files if file], | |
| "raw": {"report": report, "status": status}, | |
| } | |
| def last_token(value: object) -> str: | |
| text = str(value or "") | |
| if not text: | |
| return "" | |
| return text.replace("\\", "/").split("/")[-1] | |
| class InspectorHandler(BaseHTTPRequestHandler): | |
| state: InspectorState | |
| index_version: str | |
| def log_message(self, format: str, *args: object) -> None: | |
| print(f"[result-inspector] {self.address_string()} - {format % args}") | |
| def do_GET(self) -> None: | |
| parsed = urlparse(self.path) | |
| if parsed.path == "/": | |
| self.send_html(INDEX_HTML) | |
| return | |
| if parsed.path == "/api/version": | |
| self.send_json({"version": self.index_version}) | |
| return | |
| if parsed.path == "/api/runs": | |
| roots = self.state.runs(force=True) | |
| self.send_json( | |
| { | |
| "scan_roots": [str(root) for root in self.state.scan_roots], | |
| "runs": [run_info(self.state, root) for root in roots], | |
| } | |
| ) | |
| return | |
| if parsed.path == "/api/run-items": | |
| params = parse_qs(parsed.query) | |
| raw_root = params.get("root", [""])[0] | |
| limit = parse_int(params.get("limit", ["3000"])[0], MAX_JSONL_ITEMS) | |
| root = self.state.resolve_run(raw_root) | |
| if root is None: | |
| self.send_error(404, "run not found") | |
| return | |
| self.send_json({"root": run_info(self.state, root), **run_items(self.state, root, limit=limit)}) | |
| return | |
| if parsed.path == "/asset": | |
| params = parse_qs(parsed.query) | |
| raw_path = params.get("path", [""])[0] | |
| resolved = self.state.resolve_path(raw_path) | |
| if resolved is None: | |
| self.send_error(404, "asset not found or outside scan roots") | |
| return | |
| self.send_file(resolved) | |
| return | |
| if parsed.path.startswith("/fs/"): | |
| raw = "/" + unquote(parsed.path[len("/fs/") :]) | |
| resolved = self.state.resolve_path(raw) | |
| if resolved is None: | |
| self.send_error(404, "file not found or outside scan roots") | |
| return | |
| self.send_file(resolved) | |
| return | |
| self.send_error(404) | |
| def send_json(self, payload: dict) -> None: | |
| body = json.dumps(payload, ensure_ascii=False).encode("utf-8") | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/json; charset=utf-8") | |
| self.send_header("Content-Length", str(len(body))) | |
| self.send_header("Cache-Control", "no-store") | |
| self.end_headers() | |
| self.wfile.write(body) | |
| def send_html(self, body: str) -> None: | |
| encoded = body.encode("utf-8") | |
| self.send_response(200) | |
| self.send_header("Content-Type", "text/html; charset=utf-8") | |
| self.send_header("Content-Length", str(len(encoded))) | |
| self.send_header("Cache-Control", "no-store") | |
| self.end_headers() | |
| self.wfile.write(encoded) | |
| def send_file(self, path: Path) -> None: | |
| stat = path.stat() | |
| etag = f'"{stat.st_mtime_ns:x}-{stat.st_size:x}"' | |
| if self.headers.get("If-None-Match") == etag: | |
| self.send_response(304) | |
| self.send_header("ETag", etag) | |
| self.end_headers() | |
| return | |
| mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream" | |
| body = path.read_bytes() | |
| self.send_response(200) | |
| self.send_header("Content-Type", mime) | |
| self.send_header("Content-Length", str(len(body))) | |
| self.send_header("ETag", etag) | |
| self.send_header("Last-Modified", self.date_time_string(stat.st_mtime)) | |
| self.send_header("Cache-Control", "public, max-age=60") | |
| self.end_headers() | |
| self.wfile.write(body) | |
| def parse_int(value: str, fallback: int) -> int: | |
| try: | |
| parsed = int(value) | |
| except (TypeError, ValueError): | |
| return fallback | |
| return max(1, min(parsed, MAX_JSONL_ITEMS)) | |
| INDEX_HTML = r"""<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Pipeline Result Inspector</title> | |
| <style> | |
| :root { | |
| color-scheme: light; | |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| background: #f2f5f8; | |
| color: #162033; | |
| } | |
| * { box-sizing: border-box; } | |
| body { margin: 0; } | |
| header { | |
| height: 58px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 0 18px; | |
| background: #ffffff; | |
| border-bottom: 1px solid #d8e0ea; | |
| position: sticky; | |
| top: 0; | |
| z-index: 10; | |
| } | |
| h1 { margin: 0; font-size: 17px; letter-spacing: 0; } | |
| button, input, select { | |
| font: inherit; | |
| font-size: 13px; | |
| border: 1px solid #c8d3df; | |
| border-radius: 6px; | |
| background: #ffffff; | |
| color: #162033; | |
| } | |
| button { padding: 7px 10px; cursor: pointer; } | |
| button.active { background: #145a72; border-color: #145a72; color: #ffffff; } | |
| input, select { padding: 8px 9px; min-width: 0; } | |
| main { | |
| display: grid; | |
| grid-template-columns: 360px minmax(0, 1fr); | |
| min-height: calc(100vh - 58px); | |
| } | |
| aside { | |
| background: #f8fafc; | |
| border-right: 1px solid #d8e0ea; | |
| max-height: calc(100vh - 58px); | |
| overflow: auto; | |
| } | |
| .header-actions, .filters, .item-actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .scan-roots { | |
| padding: 12px 14px; | |
| font-size: 12px; | |
| color: #526173; | |
| line-height: 1.45; | |
| border-bottom: 1px solid #d8e0ea; | |
| word-break: break-word; | |
| } | |
| .sidebar-tools { | |
| display: grid; | |
| gap: 8px; | |
| padding: 12px 14px; | |
| border-bottom: 1px solid #d8e0ea; | |
| background: #ffffff; | |
| position: sticky; | |
| top: 0; | |
| z-index: 4; | |
| } | |
| .run { | |
| padding: 12px 14px; | |
| border-bottom: 1px solid #e2e8f0; | |
| cursor: pointer; | |
| background: #ffffff; | |
| } | |
| .run:hover, .run.selected { background: #e9f5f2; } | |
| .run-title { font-weight: 800; font-size: 13px; margin-bottom: 5px; word-break: break-word; } | |
| .meta { color: #526173; font-size: 12px; line-height: 1.45; word-break: break-word; } | |
| .content { | |
| padding: 16px; | |
| max-height: calc(100vh - 58px); | |
| overflow: auto; | |
| } | |
| .summary { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); | |
| gap: 10px; | |
| margin-bottom: 12px; | |
| } | |
| .metric, .panel { | |
| background: #ffffff; | |
| border: 1px solid #d8e0ea; | |
| border-radius: 8px; | |
| } | |
| .metric { padding: 10px 12px; min-height: 66px; } | |
| .metric-label { | |
| color: #637386; | |
| font-size: 11px; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| margin-bottom: 5px; | |
| } | |
| .metric-value { | |
| font-weight: 800; | |
| font-size: 15px; | |
| line-height: 1.25; | |
| word-break: break-word; | |
| } | |
| .panel { overflow: hidden; margin-bottom: 12px; } | |
| .panel-head { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| padding: 11px 12px; | |
| border-bottom: 1px solid #e2e8f0; | |
| background: #fbfdff; | |
| } | |
| .panel-title { font-weight: 800; font-size: 13px; } | |
| .panel-body { padding: 12px; } | |
| .item-layout { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) 380px; | |
| gap: 12px; | |
| align-items: start; | |
| } | |
| .item-list { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); | |
| gap: 10px; | |
| } | |
| .item { | |
| border: 1px solid #d8e0ea; | |
| border-radius: 8px; | |
| background: #ffffff; | |
| overflow: hidden; | |
| cursor: pointer; | |
| } | |
| .item.selected { border-color: #145a72; box-shadow: 0 0 0 2px rgba(20, 90, 114, 0.15); } | |
| .thumbs { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 1px; | |
| background: #d8e0ea; | |
| height: 150px; | |
| } | |
| .thumb { | |
| background-color: #e3e9f0; | |
| background-image: | |
| linear-gradient(45deg, rgba(100, 116, 139, 0.18) 25%, transparent 25%), | |
| linear-gradient(-45deg, rgba(100, 116, 139, 0.18) 25%, transparent 25%), | |
| linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.18) 75%), | |
| linear-gradient(-45deg, transparent 75%, rgba(100, 116, 139, 0.18) 75%); | |
| background-size: 18px 18px; | |
| background-position: 0 0, 0 9px, 9px -9px, -9px 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; | |
| } | |
| .thumb img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; } | |
| .item-body { padding: 9px 10px; } | |
| .item-title { font-weight: 800; font-size: 13px; margin-bottom: 4px; word-break: break-word; } | |
| .badge { | |
| display: inline-flex; | |
| align-items: center; | |
| min-height: 20px; | |
| padding: 2px 6px; | |
| border-radius: 5px; | |
| font-size: 11px; | |
| font-weight: 800; | |
| margin-top: 7px; | |
| max-width: 100%; | |
| word-break: break-word; | |
| } | |
| .badge.good { background: #dff4e8; color: #11613a; } | |
| .badge.warn { background: #fff1cc; color: #7a4a00; } | |
| .badge.bad { background: #ffe1dd; color: #a1281d; } | |
| .badge.neutral { background: #e7edf5; color: #45566b; } | |
| .detail-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 10px; | |
| } | |
| .file-tile { | |
| border: 1px solid #d8e0ea; | |
| border-radius: 8px; | |
| background: #ffffff; | |
| overflow: hidden; | |
| } | |
| .file-title { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| padding: 8px 9px; | |
| border-bottom: 1px solid #e2e8f0; | |
| font-size: 12px; | |
| font-weight: 800; | |
| } | |
| .image-wrap { | |
| min-height: 220px; | |
| background-color: #e3e9f0; | |
| background-image: | |
| linear-gradient(45deg, rgba(100, 116, 139, 0.18) 25%, transparent 25%), | |
| linear-gradient(-45deg, rgba(100, 116, 139, 0.18) 25%, transparent 25%), | |
| linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.18) 75%), | |
| linear-gradient(-45deg, transparent 75%, rgba(100, 116, 139, 0.18) 75%); | |
| background-size: 18px 18px; | |
| background-position: 0 0, 0 9px, 9px -9px, -9px 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 8px; | |
| } | |
| .image-wrap img { display: block; max-width: 100%; max-height: 520px; object-fit: contain; } | |
| .metrics-table { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 7px; | |
| } | |
| .small-metric { | |
| border: 1px solid #e2e8f0; | |
| border-radius: 6px; | |
| padding: 7px 8px; | |
| background: #fbfdff; | |
| min-width: 0; | |
| } | |
| .small-label { font-size: 11px; color: #637386; margin-bottom: 3px; } | |
| .small-value { font-weight: 800; font-size: 13px; word-break: break-word; } | |
| pre { | |
| margin: 0; | |
| padding: 11px; | |
| max-height: 420px; | |
| overflow: auto; | |
| background: #111827; | |
| color: #dbeafe; | |
| font-size: 12px; | |
| line-height: 1.45; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| } | |
| .empty { padding: 18px; color: #637386; } | |
| .preview-frame { | |
| width: 100%; | |
| height: 360px; | |
| border: 0; | |
| background: #ffffff; | |
| } | |
| a { color: #145a72; text-decoration: none; font-weight: 700; } | |
| @media (max-width: 1100px) { | |
| main { grid-template-columns: 1fr; } | |
| aside { max-height: 320px; border-right: 0; border-bottom: 1px solid #d8e0ea; } | |
| .content { max-height: none; } | |
| .item-layout { grid-template-columns: 1fr; } | |
| } | |
| @media (max-width: 720px) { | |
| header { height: auto; min-height: 58px; align-items: flex-start; padding: 10px 12px; gap: 10px; } | |
| .detail-grid { grid-template-columns: 1fr; } | |
| .summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>Pipeline Result Inspector</h1> | |
| <div class="header-actions"> | |
| <label class="meta"><input id="auto-refresh" type="checkbox" checked> Auto 10s</label> | |
| <button id="refresh">Refresh</button> | |
| </div> | |
| </header> | |
| <main> | |
| <aside> | |
| <div id="scan-roots" class="scan-roots">Loading...</div> | |
| <div class="sidebar-tools"> | |
| <input id="run-search" placeholder="Filter runs"> | |
| <select id="kind-filter"> | |
| <option value="">All kinds</option> | |
| <option value="comparison">comparison</option> | |
| <option value="manifest">manifest</option> | |
| <option value="fci">fci</option> | |
| <option value="preview">preview</option> | |
| </select> | |
| </div> | |
| <div id="runs"></div> | |
| </aside> | |
| <section id="content" class="content"> | |
| <div class="empty">Loading...</div> | |
| </section> | |
| </main> | |
| <script> | |
| let runs = []; | |
| let selectedRun = null; | |
| let items = []; | |
| let selectedItem = null; | |
| let itemSearch = ""; | |
| let statusFilter = ""; | |
| let autoTimer = null; | |
| let pageVersion = null; | |
| const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({ | |
| "&": "&", "<": "<", ">": ">", '"': """, "'": "'" | |
| }[ch])); | |
| const fmt = (value) => value === undefined || value === null || value === "" ? "-" : String(value); | |
| const fmtNum = (value) => { | |
| if (value === undefined || value === null || value === "") return "-"; | |
| const n = Number(value); | |
| if (!Number.isFinite(n)) return String(value); | |
| if (Math.abs(n) >= 100) return n.toFixed(1); | |
| return n.toFixed(4).replace(/\.?0+$/, ""); | |
| }; | |
| const assetUrl = (path, version = "") => "/asset?path=" + encodeURIComponent(path) + (version ? "&v=" + encodeURIComponent(version) : ""); | |
| const fileSrc = (file) => file.url || assetUrl(file.path, file.version); | |
| const imageFiles = (item) => (item?.files || []).filter((file) => file.type === "image" && file.exists); | |
| const firstImages = (item) => imageFiles(item).slice(0, 2); | |
| const statusClass = (status) => { | |
| const text = String(status || "").toLowerCase(); | |
| if (text.includes("regressed") || text.includes("failure") || text.includes("failed") || text.includes("false")) return "bad"; | |
| if (text.includes("fixed")) return "warn"; | |
| if (text.includes("success") || text.includes("true") || text.includes("passed")) return "good"; | |
| return "neutral"; | |
| }; | |
| const updatedLabel = (seconds) => { | |
| if (!seconds) return "-"; | |
| return new Date(seconds * 1000).toLocaleString(); | |
| }; | |
| async function loadRuns({keepSelection = true} = {}) { | |
| const response = await fetch("/api/runs", {cache: "no-store"}); | |
| const data = await response.json(); | |
| const previous = selectedRun?.path; | |
| runs = data.runs || []; | |
| document.getElementById("scan-roots").innerHTML = | |
| "<b>Scan roots</b><br>" + (data.scan_roots || []).map(esc).join("<br>"); | |
| renderRuns(); | |
| if (!runs.length) { | |
| selectedRun = null; | |
| items = []; | |
| selectedItem = null; | |
| renderContent(); | |
| return; | |
| } | |
| if (keepSelection && previous) { | |
| selectedRun = runs.find((run) => run.path === previous) || runs[0]; | |
| } else if (!selectedRun) { | |
| selectedRun = runs[0]; | |
| } else { | |
| selectedRun = runs.find((run) => run.path === selectedRun.path) || runs[0]; | |
| } | |
| renderRuns(); | |
| await loadItems(selectedRun.path); | |
| } | |
| async function loadItems(rootPath) { | |
| const response = await fetch("/api/run-items?root=" + encodeURIComponent(rootPath) + "&limit=3000", {cache: "no-store"}); | |
| const data = await response.json(); | |
| selectedRun = data.root; | |
| items = data.items || []; | |
| selectedItem = items[0] || null; | |
| renderRuns(); | |
| renderContent(data); | |
| } | |
| function renderRuns() { | |
| const node = document.getElementById("runs"); | |
| const q = document.getElementById("run-search").value.trim().toLowerCase(); | |
| const kind = document.getElementById("kind-filter").value; | |
| const filtered = runs.filter((run) => { | |
| const text = [run.name, run.path, run.kind, (run.markers || []).join(" ")].join(" ").toLowerCase(); | |
| const kindMatch = !kind || run.kind.includes(kind); | |
| return kindMatch && (!q || text.includes(q)); | |
| }); | |
| node.innerHTML = filtered.map((run) => ` | |
| <div class="run ${selectedRun?.path === run.path ? "selected" : ""}" onclick="selectRun(${JSON.stringify(run.path).replace(/"/g, """)})"> | |
| <div class="run-title">${esc(run.name)}</div> | |
| <div class="meta"> | |
| ${esc(run.kind)} | ${esc(run.item_count)} items<br> | |
| ${esc(updatedLabel(run.updated))}<br> | |
| ${esc((run.markers || []).join(", ") || "-")} | |
| </div> | |
| </div> | |
| `).join("") || '<div class="empty">No runs found.</div>'; | |
| } | |
| async function selectRun(path) { | |
| selectedRun = runs.find((run) => run.path === path) || null; | |
| selectedItem = null; | |
| items = []; | |
| renderRuns(); | |
| renderContent(); | |
| if (selectedRun) await loadItems(selectedRun.path); | |
| } | |
| function selectItem(id) { | |
| selectedItem = items.find((item) => item.id === id) || null; | |
| renderContent(); | |
| } | |
| function filteredItems() { | |
| const search = itemSearch.trim().toLowerCase(); | |
| const status = statusFilter; | |
| return items.filter((item) => { | |
| const text = [item.title, item.subtitle, item.status, JSON.stringify(item.metrics || {})].join(" ").toLowerCase(); | |
| return (!search || text.includes(search)) && (!status || item.status_class === status); | |
| }); | |
| } | |
| function renderContent(loadMeta = null, focusId = "") { | |
| const node = document.getElementById("content"); | |
| if (!selectedRun) { | |
| node.innerHTML = '<div class="empty">No run selected.</div>'; | |
| return; | |
| } | |
| const list = filteredItems(); | |
| if (selectedItem && !list.find((item) => item.id === selectedItem.id)) { | |
| selectedItem = list[0] || null; | |
| } | |
| node.innerHTML = ` | |
| ${renderRunSummary(loadMeta)} | |
| ${selectedRun.preview ? renderPreviewPanel(selectedRun.preview) : ""} | |
| <div class="item-layout"> | |
| <div class="panel"> | |
| <div class="panel-head"> | |
| <div class="panel-title">Items</div> | |
| <div class="filters"> | |
| <input id="item-search" placeholder="Filter items" value="${esc(itemSearch)}"> | |
| <select id="status-filter"> | |
| <option value="">All statuses</option> | |
| <option value="bad" ${statusFilter === "bad" ? "selected" : ""}>bad</option> | |
| <option value="warn" ${statusFilter === "warn" ? "selected" : ""}>warn</option> | |
| <option value="good" ${statusFilter === "good" ? "selected" : ""}>good</option> | |
| <option value="neutral" ${statusFilter === "neutral" ? "selected" : ""}>neutral</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="panel-body"> | |
| <div class="item-list"> | |
| ${list.map(renderItemCard).join("") || '<div class="empty">No items match.</div>'} | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| ${selectedItem ? renderDetail(selectedItem) : '<div class="panel"><div class="empty">No item selected.</div></div>'} | |
| </div> | |
| </div> | |
| `; | |
| const search = document.getElementById("item-search"); | |
| const status = document.getElementById("status-filter"); | |
| if (search) search.addEventListener("input", (event) => { | |
| itemSearch = event.target.value; | |
| renderContent(null, "item-search"); | |
| }); | |
| if (status) status.addEventListener("change", (event) => { | |
| statusFilter = event.target.value; | |
| renderContent(null, "status-filter"); | |
| }); | |
| if (focusId) { | |
| const focused = document.getElementById(focusId); | |
| if (focused) { | |
| focused.focus(); | |
| if (focused.setSelectionRange && focusId === "item-search") { | |
| focused.setSelectionRange(focused.value.length, focused.value.length); | |
| } | |
| } | |
| } | |
| } | |
| function renderRunSummary(loadMeta) { | |
| const summary = selectedRun.summary || {}; | |
| const config = selectedRun.config || {}; | |
| const statusCounts = summary.status_counts || {}; | |
| return ` | |
| <div class="summary"> | |
| <div class="metric"><div class="metric-label">Run</div><div class="metric-value">${esc(selectedRun.name)}</div></div> | |
| <div class="metric"><div class="metric-label">Kind</div><div class="metric-value">${esc(selectedRun.kind)}</div></div> | |
| <div class="metric"><div class="metric-label">Items</div><div class="metric-value">${esc(loadMeta?.total ?? selectedRun.item_count ?? items.length)}</div></div> | |
| <div class="metric"><div class="metric-label">Templates</div><div class="metric-value">${esc(summary.total_templates ?? "-")}</div></div> | |
| <div class="metric"><div class="metric-label">Same Success</div><div class="metric-value">${esc(statusCounts.same_success ?? "-")}</div></div> | |
| <div class="metric"><div class="metric-label">Regressed</div><div class="metric-value">${esc(statusCounts.regressed_failure ?? "-")}</div></div> | |
| <div class="metric"><div class="metric-label">Workers</div><div class="metric-value">${esc(config.workers ?? "-")}</div></div> | |
| <div class="metric"><div class="metric-label">Updated</div><div class="metric-value">${esc(updatedLabel(selectedRun.updated))}</div></div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-head"> | |
| <div class="panel-title">Run Files</div> | |
| <div class="meta">${esc(selectedRun.path)}</div> | |
| </div> | |
| <div class="panel-body"> | |
| <div class="metrics-table"> | |
| ${Object.entries(summary).map(([key, value]) => renderSmallMetric(key, typeof value === "object" ? JSON.stringify(value) : value)).join("")} | |
| ${Object.entries(config).slice(0, 8).map(([key, value]) => renderSmallMetric(key, Array.isArray(value) ? value.join(", ") : value)).join("")} | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| function renderPreviewPanel(file) { | |
| return ` | |
| <div class="panel"> | |
| <div class="panel-head"> | |
| <div class="panel-title">Preview</div> | |
| <a href="${esc(file.url)}" target="_blank" rel="noreferrer">Open</a> | |
| </div> | |
| <iframe class="preview-frame" src="${esc(file.url)}"></iframe> | |
| </div> | |
| `; | |
| } | |
| function renderItemCard(item) { | |
| const images = firstImages(item); | |
| const slots = [images[0], images[1]]; | |
| return ` | |
| <div class="item ${selectedItem?.id === item.id ? "selected" : ""}" onclick="selectItem(${JSON.stringify(item.id).replace(/"/g, """)})"> | |
| <div class="thumbs"> | |
| ${slots.map((file) => ` | |
| <div class="thumb">${file ? `<img loading="lazy" decoding="async" src="${esc(fileSrc(file))}">` : ""}</div> | |
| `).join("")} | |
| </div> | |
| <div class="item-body"> | |
| <div class="item-title">${esc(item.title)}</div> | |
| <div class="meta">${esc(item.subtitle)}</div> | |
| <span class="badge ${esc(item.status_class || statusClass(item.status))}">${esc(item.status)}</span> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| function renderDetail(item) { | |
| const files = item.files || []; | |
| return ` | |
| <div class="panel"> | |
| <div class="panel-head"> | |
| <div> | |
| <div class="panel-title">${esc(item.title)}</div> | |
| <div class="meta">${esc(item.subtitle)}</div> | |
| </div> | |
| <span class="badge ${esc(item.status_class || statusClass(item.status))}">${esc(item.status)}</span> | |
| </div> | |
| <div class="panel-body"> | |
| <div class="metrics-table"> | |
| ${Object.entries(item.metrics || {}).map(([key, value]) => renderSmallMetric(key, value)).join("")} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-head"><div class="panel-title">Files</div></div> | |
| <div class="panel-body"> | |
| <div class="detail-grid"> | |
| ${files.map(renderFileTile).join("") || '<div class="empty">No files recorded.</div>'} | |
| </div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-head"><div class="panel-title">Raw Row</div></div> | |
| <pre>${esc(JSON.stringify(item.raw, null, 2))}</pre> | |
| </div> | |
| `; | |
| } | |
| function renderSmallMetric(label, value) { | |
| return ` | |
| <div class="small-metric"> | |
| <div class="small-label">${esc(label)}</div> | |
| <div class="small-value">${esc(typeof value === "number" ? fmtNum(value) : fmt(value))}</div> | |
| </div> | |
| `; | |
| } | |
| function renderFileTile(file) { | |
| if (!file.exists) { | |
| return ` | |
| <div class="file-tile"> | |
| <div class="file-title">${esc(file.label)}<span class="badge bad">missing</span></div> | |
| <pre>${esc(file.path)}</pre> | |
| </div> | |
| `; | |
| } | |
| const source = fileSrc(file); | |
| const body = file.type === "image" | |
| ? `<div class="image-wrap"><img loading="lazy" decoding="async" src="${esc(source)}"></div>` | |
| : file.type === "html" | |
| ? `<iframe class="preview-frame" src="${esc(source)}"></iframe>` | |
| : `<pre>${esc(file.path)}</pre>`; | |
| return ` | |
| <div class="file-tile"> | |
| <div class="file-title"> | |
| <span>${esc(file.label)}</span> | |
| <a href="${esc(source)}" target="_blank" rel="noreferrer">Open</a> | |
| </div> | |
| ${body} | |
| </div> | |
| `; | |
| } | |
| function setAutoRefresh(enabled) { | |
| if (autoTimer) clearInterval(autoTimer); | |
| autoTimer = enabled ? setInterval(() => loadRuns({keepSelection: true}).catch(showError), 10000) : null; | |
| } | |
| async function checkVersion() { | |
| try { | |
| const response = await fetch("/api/version", {cache: "no-store"}); | |
| const data = await response.json(); | |
| if (!data.version) return; | |
| if (pageVersion === null) { | |
| pageVersion = data.version; | |
| } else if (pageVersion !== data.version) { | |
| window.location.reload(); | |
| } | |
| } catch { | |
| } | |
| } | |
| function showError(error) { | |
| document.getElementById("content").innerHTML = '<div class="empty">' + esc(error.message || error) + '</div>'; | |
| } | |
| document.getElementById("refresh").addEventListener("click", () => loadRuns({keepSelection: true}).catch(showError)); | |
| document.getElementById("auto-refresh").addEventListener("change", (event) => setAutoRefresh(event.target.checked)); | |
| document.getElementById("run-search").addEventListener("input", renderRuns); | |
| document.getElementById("kind-filter").addEventListener("change", renderRuns); | |
| setAutoRefresh(true); | |
| checkVersion(); | |
| setInterval(checkVersion, 3000); | |
| loadRuns({keepSelection: false}).catch(showError); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| def main() -> None: | |
| args = parse_args() | |
| scan_roots = args.scan_root or DEFAULT_SCAN_ROOTS | |
| state = InspectorState(scan_roots, args.max_depth) | |
| version = hashlib.sha256(INDEX_HTML.encode("utf-8")).hexdigest()[:16] | |
| handler = type("PipelineResultInspectorHandler", (InspectorHandler,), {"state": state, "index_version": version}) | |
| server = ThreadingHTTPServer((args.host, args.port), handler) | |
| print(f"Serving result inspector at http://{args.host}:{args.port}") | |
| print("Scan roots: " + ", ".join(html.escape(str(root)) for root in state.scan_roots)) | |
| try: | |
| server.serve_forever() | |
| except KeyboardInterrupt: | |
| pass | |
| finally: | |
| server.server_close() | |
| if __name__ == "__main__": | |
| main() | |