ChartPipeline / scripts /result_inspector.py
Ray1ee01's picture
Upload folder using huggingface_hub
58e6885 verified
Raw
History Blame Contribute Delete
47.6 kB
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
@property
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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[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, "&quot;")})">
<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, "&quot;")})">
<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()