| """FastAPI viewer for Harbor train + eval runs. The data lives in a bucket; this only renders it. |
| |
| ONE READ PATH, TWO DEPLOYMENTS. A Hugging Face bucket mounted into a Space appears as an ordinary |
| filesystem path inside the container (`Volume(type="bucket", source=..., mount_path="/data")`), so the |
| Space and a laptop run the *same* code against the same layout — `DATA_DIR` is just `/data` in one case |
| and `./data` in the other. The alternative, branching between a local reader and an `HfFileSystem` |
| reader, means two code paths where only one is ever exercised by whoever is debugging. |
| |
| `DATA_BUCKET` exists for the case the mount cannot cover: reading a remote bucket from a laptop without |
| mounting it. It goes through `HfFileSystem`, which speaks `hf://buckets/<ns>/<name>/<path>`. When both |
| are set the local directory wins, because a mount is always fresher and cheaper than the network. |
| |
| DATA_DIR = ./data # a local dir, or a mounted bucket at /data |
| DATA_BUCKET = (unset) # e.g. AdithyaSK/harbor-runs — remote fallback |
| REFRESH_TTL = 30 # seconds a listing is trusted before rescanning |
| |
| WHY A REFRESH BUTTON AND NOT A WATCHER. A run in progress appends to `metrics.jsonl` and drops new files |
| into `traces/`; a mounted bucket reflects that without the app doing anything. But listings are cached |
| so that a page load does not restat thousands of files, so the UI needs a way to say "look again now" — |
| `POST /api/refresh` drops every cache. Nothing is precomputed, so refresh is the only invalidation |
| needed. |
| |
| HIERARCHY: project -> dataset -> task (row) -> cell (model x harness) -> attempts (pass@k) -> trace. |
| Cells are keyed "<model>|<harness>" so the viewer can pivot which axis is the column without refetching; |
| that is a view choice, and storing it per-axis would force a rewrite to flip the table. |
| |
| Endpoints: |
| GET / → the viewer |
| GET /api/projects → tree: projects, their datasets and runs |
| GET /api/projects/{pid}/datasets/{did} → the table (tasks + cells + aggregates) |
| GET /api/projects/{pid}/datasets/{did}/trace?path= → one attempt |
| GET /api/projects/{pid}/runs/{rid} → a training run's run.json |
| GET /api/projects/{pid}/runs/{rid}/train → its metrics.jsonl, parsed |
| POST /api/refresh → drop caches; returns what it now sees |
| GET /healthz → {ok, source, n_projects, ...} |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| import time |
| from pathlib import PurePosixPath |
| from typing import Any |
|
|
| from fastapi import FastAPI, HTTPException, Query |
| from fastapi.responses import HTMLResponse, JSONResponse |
|
|
| HERE = os.path.dirname(os.path.abspath(__file__)) |
| SITE = os.path.join(HERE, "site") |
| DATA_DIR = os.getenv("DATA_DIR", os.path.join(HERE, "data")) |
| DATA_BUCKET = os.getenv("DATA_BUCKET", "") |
| REFRESH_TTL = float(os.getenv("REFRESH_TTL", "30")) |
|
|
| app = FastAPI(title="Harbor run viewer", docs_url="/docs") |
|
|
| _cache: dict[str, Any] = {} |
| _cache_at: dict[str, float] = {} |
|
|
|
|
| |
| class _Local: |
| kind = "local" |
|
|
| def __init__(self, root: str) -> None: |
| self.root = root |
|
|
| def describe(self) -> str: |
| return f"local:{self.root}" |
|
|
| def exists(self, rel: str) -> bool: |
| return os.path.exists(os.path.join(self.root, rel)) |
|
|
| def listdir(self, rel: str) -> list[str]: |
| p = os.path.join(self.root, rel) |
| return sorted(os.listdir(p)) if os.path.isdir(p) else [] |
|
|
| def read_text(self, rel: str) -> str: |
| with open(os.path.join(self.root, rel), encoding="utf-8") as f: |
| return f.read() |
|
|
| def mtime(self, rel: str) -> float: |
| try: |
| return os.path.getmtime(os.path.join(self.root, rel)) |
| except OSError: |
| return 0.0 |
|
|
|
|
| class _Bucket: |
| """`hf://buckets/<ns>/<name>/<path>` over HfFileSystem. Used only when nothing is mounted.""" |
|
|
| kind = "bucket" |
|
|
| def __init__(self, bucket: str) -> None: |
| from huggingface_hub import HfFileSystem |
|
|
| self.bucket = bucket |
| self.fs = HfFileSystem() |
|
|
| def _p(self, rel: str) -> str: |
| return f"buckets/{self.bucket}/{rel}".rstrip("/") |
|
|
| def describe(self) -> str: |
| return f"hf://buckets/{self.bucket}" |
|
|
| def exists(self, rel: str) -> bool: |
| return bool(self.fs.exists(self._p(rel))) |
|
|
| def listdir(self, rel: str) -> list[str]: |
| try: |
| return sorted(PurePosixPath(p).name for p in self.fs.ls(self._p(rel), detail=False)) |
| except FileNotFoundError: |
| return [] |
|
|
| def read_text(self, rel: str) -> str: |
| with self.fs.open(self._p(rel), "r") as f: |
| return f.read() |
|
|
| def mtime(self, rel: str) -> float: |
| return 0.0 |
|
|
|
|
| def store(): |
| """Prefer a real directory: a mounted bucket is fresher and cheaper than the network.""" |
| if os.path.isdir(DATA_DIR): |
| return _Local(DATA_DIR) |
| if DATA_BUCKET: |
| return _Bucket(DATA_BUCKET) |
| return _Local(DATA_DIR) |
|
|
|
|
| def _cached(key: str, produce): |
| now = time.time() |
| if key in _cache and now - _cache_at.get(key, 0.0) < REFRESH_TTL: |
| return _cache[key] |
| value = produce() |
| _cache[key] = value |
| _cache_at[key] = now |
| return value |
|
|
|
|
| def _safe_rel(*parts: str) -> str: |
| """Join a relative path and refuse to escape the data directory. |
| |
| Trace paths come straight from summary.json, which this app did not write, so a `../` in one of them |
| must not read outside DATA_DIR. |
| """ |
| for part in parts: |
| if not part or part.startswith("/") or ".." in PurePosixPath(part).parts: |
| raise HTTPException(400, f"unsafe path component: {part!r}") |
| return str(PurePosixPath(*parts)) |
|
|
|
|
| def _read_json(st, rel: str) -> Any: |
| if not st.exists(rel): |
| raise HTTPException(404, f"missing: {st.describe()}/{rel}") |
| try: |
| return json.loads(st.read_text(rel)) |
| except json.JSONDecodeError as exc: |
| raise HTTPException(500, f"{rel} is not valid JSON: {exc}") from exc |
|
|
|
|
| def _list_projects() -> list[dict]: |
| """A directory with project.json is a project. Datasets and runs are listed alongside it so the |
| sidebar can render the whole tree from one request instead of N+1.""" |
| st = store() |
| out = [] |
| for pid in st.listdir("projects"): |
| rel = f"projects/{pid}/project.json" |
| if not st.exists(rel): |
| continue |
| try: |
| meta = json.loads(st.read_text(rel)) |
| except Exception: |
| meta = {"project_id": pid, "error": "project.json is unreadable"} |
| meta.setdefault("project_id", pid) |
| meta["datasets"] = [ |
| d for d in st.listdir(f"projects/{pid}/datasets") |
| if st.exists(f"projects/{pid}/datasets/{d}/summary.json") |
| ] |
| meta["runs"] = [ |
| r for r in st.listdir(f"projects/{pid}/runs") |
| if st.exists(f"projects/{pid}/runs/{r}/run.json") |
| ] |
| out.append(meta) |
| out.sort(key=lambda p: p.get("label") or p["project_id"]) |
| return out |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| def index() -> HTMLResponse: |
| p = os.path.join(SITE, "viewer.html") |
| if not os.path.exists(p): |
| raise HTTPException(503, "site/viewer.html is missing") |
| with open(p, encoding="utf-8") as f: |
| return HTMLResponse(f.read()) |
|
|
|
|
| @app.get("/api/projects") |
| def projects() -> JSONResponse: |
| return JSONResponse(_cached("projects", _list_projects)) |
|
|
|
|
| @app.get("/api/projects/{pid}/datasets/{did}") |
| def dataset(pid: str, did: str) -> JSONResponse: |
| """The table: tasks as rows, cells keyed '<model>|<harness>'. The viewer pivots which axis is the |
| column, so this is returned once and re-rendered client-side rather than fetched per view.""" |
| rel = _safe_rel("projects", pid, "datasets", did, "summary.json") |
| return JSONResponse(_cached(f"ds:{pid}/{did}", lambda: _read_json(store(), rel))) |
|
|
|
|
| @app.get("/api/projects/{pid}/datasets/{did}/trace") |
| def trace(pid: str, did: str, path: str = Query(..., description="path relative to the dataset dir")) -> JSONResponse: |
| """One attempt. `path` comes from summary.json, which this app did not write, so it is validated |
| against escaping the data directory before being opened.""" |
| rel = _safe_rel("projects", pid, "datasets", did, *PurePosixPath(path).parts) |
| return JSONResponse(_read_json(store(), rel)) |
|
|
|
|
| @app.get("/api/projects/{pid}/runs/{rid}") |
| def run(pid: str, rid: str) -> JSONResponse: |
| return JSONResponse( |
| _cached(f"run:{pid}/{rid}", lambda: _read_json(store(), _safe_rel("projects", pid, "runs", rid, "run.json"))) |
| ) |
|
|
|
|
| @app.get("/api/projects/{pid}/runs/{rid}/train") |
| def run_train(pid: str, rid: str) -> JSONResponse: |
| """metrics.jsonl -> list. A malformed final line is skipped rather than fatal: a run being appended |
| to right now can have a half-written line, and refusing the file would make live runs unviewable.""" |
|
|
| def produce(): |
| st = store() |
| rel = _safe_rel("projects", pid, "runs", rid, "train", "metrics.jsonl") |
| if not st.exists(rel): |
| raise HTTPException(404, f"missing: {st.describe()}/{rel}") |
| rows, skipped = [], 0 |
| for line in st.read_text(rel).splitlines(): |
| line = line.strip() |
| if not line: |
| continue |
| try: |
| rows.append(json.loads(line)) |
| except json.JSONDecodeError: |
| skipped += 1 |
| return {"steps": rows, "skipped_lines": skipped} |
|
|
| return JSONResponse(_cached(f"train:{pid}/{rid}", produce)) |
|
|
|
|
| @app.post("/api/refresh") |
| def refresh() -> JSONResponse: |
| """Drop every cache and rescan. A mounted bucket already reflects new writes; this is what makes |
| the UI notice them without a restart.""" |
| _cache.clear() |
| _cache_at.clear() |
| listing = _list_projects() |
| return JSONResponse({ |
| "refreshed": True, |
| "source": store().describe(), |
| "projects": len(listing), |
| "datasets": sum(len(p.get("datasets", [])) for p in listing), |
| "runs": sum(len(p.get("runs", [])) for p in listing), |
| }) |
|
|
|
|
| @app.get("/healthz") |
| def healthz() -> dict: |
| st = store() |
| return { |
| "ok": os.path.exists(os.path.join(SITE, "viewer.html")), |
| "source": st.describe(), |
| "source_kind": st.kind, |
| "data_dir_exists": os.path.isdir(DATA_DIR), |
| "bucket_fallback": DATA_BUCKET or None, |
| "n_projects": len(_list_projects()), |
| "refresh_ttl_s": REFRESH_TTL, |
| } |
|
|