#!/usr/bin/env python3 """Show progress, throughput, and bottlenecks for the one-shot Web smoke run. The output follows the observable-state style of show_freecad_web_scheduler.py and includes its scheduler status anchor for correlation. """ from __future__ import annotations import argparse import json import statistics import time from datetime import datetime from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MANIFEST = Path(__import__("os").environ.get("FREECAD_SMOKE_RUN_DIR", str(ROOT / "docs/current/module-smoke-once"))).expanduser().resolve() / "manifest.json" SCHEDULER_STATUS = ROOT / "docs/current/freecad-web-scheduler-status.json" def parse_time(value: object) -> float | None: if not isinstance(value, str): return None try: return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() except ValueError: return None def scheduler_snapshot() -> dict[str, object]: """Expose the same scheduler status anchor used by the scheduler viewer.""" try: value = json.loads(SCHEDULER_STATUS.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {"status": "unknown"} return {key: value.get(key) for key in ("status", "cycle", "activeTask", "unresolvedFailures", "heartbeatAt")} def snapshot() -> dict[str, object]: if not MANIFEST.exists(): return {"status": "not_started", "completed": 0, "total": 0, "message": str(MANIFEST)} try: data = json.loads(MANIFEST.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: return {"status": "unreadable", "message": str(exc)} results = data.get("results", {}) if isinstance(data.get("results", {}), dict) else {} total = len(data.get("modules", [])) completed = len(results) passed = sum(isinstance(v, dict) and v.get("status") == "pass" for v in results.values()) failed = sum(isinstance(v, dict) and v.get("status") != "pass" for v in results.values()) started = parse_time(data.get("startedAt")) now = parse_time(data.get("completedAt")) or time.time() elapsed = max(0.0, now - started) if started else 0.0 durations = [float(v["durationSeconds"]) for v in results.values() if isinstance(v, dict) and isinstance(v.get("durationSeconds"), (int, float))] average = statistics.mean(durations) if durations else 0.0 median = statistics.median(durations) if durations else 0.0 remaining = max(0, total - completed) current = data.get("current") if isinstance(data.get("current"), dict) else None current_elapsed = max(0.0, time.time() - float(current["startedEpoch"])) if current and isinstance(current.get("startedEpoch"), (int, float)) else 0.0 completed_per_minute = completed / (elapsed / 60.0) if elapsed > 0 else 0.0 slowest = sorted( ((name, float(v.get("durationSeconds", 0.0)), v.get("status")) for name, v in results.items() if isinstance(v, dict)), key=lambda item: item[1], reverse=True, )[:5] phase_seconds = { "compile": round(sum(float(v.get("compileDurationSeconds", 0.0)) for v in results.values() if isinstance(v, dict)), 3), "build": round(sum(float(v.get("buildDurationSeconds", 0.0)) for v in results.values() if isinstance(v, dict)), 3), "smoke": round(sum(float(v.get("smokeDurationSeconds", v.get("durationSeconds", 0.0))) for v in results.values() if isinstance(v, dict) and v.get("phase") == "smoke"), 3), } timeout_count = sum(1 for v in results.values() if isinstance(v, dict) and v.get("timeoutSeconds")) build_failures = sum(isinstance(v, dict) and v.get("phase") == "build" for v in results.values()) compile_failures = sum(isinstance(v, dict) and v.get("phase") == "compile" for v in results.values()) failure_classes: dict[str, int] = {} for value in results.values(): if not isinstance(value, dict) or value.get("status") == "pass": continue stderr = str(value.get("stderr", "")) if value.get("timeoutSeconds") or value.get("interrupted"): category = "timeout_or_interrupted" elif "require is not defined in ES module scope" in stderr: category = "esm_cjs_worker" elif "compiled " in stderr and "path is required" in stderr: category = "missing_facade_argument" elif "ERR_MODULE_NOT_FOUND" in stderr: category = "missing_runtime_artifact" elif "FROZEN_CACHE" in stderr or "cache" in stderr.lower() and value.get("phase") == "build": category = "build_cache" elif value.get("phase") == "build": category = "build_failure" else: category = "runtime_or_assertion" failure_classes[category] = failure_classes.get(category, 0) + 1 measured_total = sum(duration for _, duration, _ in slowest) bottleneck = slowest[0] if slowest else None return { "status": data.get("status", "unknown"), "completed": completed, "total": total, "passed": passed, "failed": failed, "remaining": remaining, "elapsedSeconds": round(elapsed, 3), "completedPerMinute": round(completed_per_minute, 3), "averageDurationSeconds": round(average, 3), "medianDurationSeconds": round(median, 3), "estimatedRemainingSeconds": round(remaining * median, 3), "current": current, "currentElapsedSeconds": round(current_elapsed, 3), "slowest": slowest, "phaseSeconds": phase_seconds, "timeouts": timeout_count, "buildFailures": build_failures, "compileFailures": compile_failures, "efficiencyVerdict": ( "LOW: timeout dominates smoke time" if timeout_count and slowest else "MIXED: build/compile failures present" if build_failures or compile_failures else "GOOD: no timeout or preparation bottleneck" ), "failureClasses": failure_classes, "bottleneck": ({"module": bottleneck[0], "seconds": bottleneck[1], "shareOfTop5": round(100.0 * bottleneck[1] / measured_total, 2)} if bottleneck and measured_total else None), "failedModules": [name for name, v in results.items() if isinstance(v, dict) and v.get("status") != "pass"], "scheduler": scheduler_snapshot(), } def render(view: dict[str, object]) -> str: if view.get("status") in {"not_started", "unreadable"}: return json.dumps(view, ensure_ascii=False, indent=2) total = int(view["total"]) completed = int(view["completed"]) percent = (100.0 * completed / total) if total else 0.0 lines = [ f"status={view['status']} progress={completed}/{total} ({percent:.1f}%) pass={view['passed']} fail={view['failed']} remaining={view['remaining']}", f"elapsed={view['elapsedSeconds']}s throughput={view['completedPerMinute']}/min average/module={view['averageDurationSeconds']}s median/module={view['medianDurationSeconds']}s est_remaining={view['estimatedRemainingSeconds']}s", f"phaseSeconds: compile={view['phaseSeconds']['compile']} build={view['phaseSeconds']['build']} smoke={view['phaseSeconds']['smoke']} timeouts={view['timeouts']} buildFailures={view['buildFailures']} compileFailures={view['compileFailures']}", f"efficiencyVerdict={view['efficiencyVerdict']}", ] current = view.get("current") if isinstance(current, dict): lines.append(f"current={current.get('module')} phase={current.get('phase')} elapsed={view['currentElapsedSeconds']}s timeout={current.get('timeoutSeconds', '-')}") if view["slowest"]: lines.append("slowest=" + ", ".join(f"{name}:{duration:.3f}s/{status}" for name, duration, status in view["slowest"])) if view.get("bottleneck"): b = view["bottleneck"] lines.append(f"bottleneck={b['module']} {b['seconds']:.3f}s ({b['shareOfTop5']:.1f}% of top-5 measured time)") if view.get("failureClasses"): lines.append("failureClasses=" + ", ".join(f"{name}:{count}" for name, count in sorted(view["failureClasses"].items(), key=lambda item: (-item[1], item[0])))) if view["failedModules"]: lines.append("failed=" + ", ".join(view["failedModules"])) scheduler = view.get("scheduler") if isinstance(scheduler, dict): lines.append( "scheduler(reference=tools/show_freecad_web_scheduler.py): " f"status={scheduler.get('status', '-')} cycle={scheduler.get('cycle', '-')} " f"activeTask={scheduler.get('activeTask', '-')} unresolvedFailures={scheduler.get('unresolvedFailures', '-') }" ) return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--watch", type=float, metavar="SECONDS", help="refresh continuously") parser.add_argument("--json", action="store_true", help="emit a JSON snapshot") args = parser.parse_args() while True: view = snapshot() print(json.dumps(view, ensure_ascii=False) if args.json else render(view), flush=True) if not args.watch: return 0 time.sleep(max(0.2, args.watch)) print("\033[2J\033[H", end="") if __name__ == "__main__": raise SystemExit(main())