1"""FastAPI app: browse past Elodin runs, list sensors, plot, and export CSV.
3Run (dev): uvicorn backend.main:app --reload --port 8000
4Prod-ish: build the frontend to frontend/dist and this process serves it too.
7from __future__
import annotations
9from pathlib
import Path
11from fastapi
import Body, FastAPI, HTTPException, Query
12from fastapi.middleware.cors
import CORSMiddleware
13from fastapi.responses
import FileResponse, PlainTextResponse, StreamingResponse
14from fastapi.staticfiles
import StaticFiles
16from .
import config, descriptions, export_cache, run_config, runs, series, summary, video
18app = FastAPI(title=
"Elodin Past-Run Viewer")
33 return [c
for c
in components.split(
",")
if c]
37 """Which clock the x-axis is. Defaults to the sensor clock; see series.py."""
38 if value
not in series.TIME_SOURCES:
39 raise HTTPException(400, f
"time_source must be one of {series.TIME_SOURCES}")
45 return runs.list_runs()
48@app.put("/api/runs/{run_id}/description")
50 """Set (or clear, with empty text) this run's shared one-line description.
52 Unowned on purpose: there is no login on this viewer, so anyone who can see a run can
53 label it and everyone else sees that label. Last write wins.
55 if not config.RUN_RE.match(run_id):
56 raise HTTPException(400,
"invalid run id")
57 if not (config.ELODIN_DIR / run_id).is_dir():
58 raise HTTPException(404, f
"run not found: {run_id}")
60 entry = descriptions.set_text(run_id, text)
64 raise HTTPException(503, f
"could not save description: {e}")
65 return {
"run_id": run_id,
"description": entry[
"text"],
"updated_at": entry[
"updated_at"]}
68@app.get("/api/runs/{run_id}/summary")
70 """What is knowable about a run without exporting it — size, component count and an
71 approximate duration. Sub-second; safe to call on every run selection."""
72 if not config.RUN_RE.match(run_id):
73 raise HTTPException(400,
"invalid run id")
74 if not (config.ELODIN_DIR / run_id).is_dir():
75 raise HTTPException(404, f
"run not found: {run_id}")
76 return summary.summarize(run_id)
79@app.get("/api/runs/{run_id}/components")
81 """The component index. Only for an already-indexed run: selecting a run must not
82 cost a multi-second export, so building one is POST /index, an explicit action."""
83 if not config.RUN_RE.match(run_id):
84 raise HTTPException(400,
"invalid run id")
85 if not export_cache.is_cached(run_id):
86 raise HTTPException(409,
"run is not indexed yet")
88 return export_cache.get_index(run_id)
89 except FileNotFoundError
as e:
90 raise HTTPException(404, str(e))
91 except RuntimeError
as e:
92 raise HTTPException(500, str(e))
95@app.post("/api/runs/{run_id}/index")
97 """Export the run to parquet and build its index — the expensive step, run only when
98 asked for. Synchronous: tens of seconds on a multi-GB run, and the caller shows a
99 busy state for the duration. Concurrent callers share one export (export_cache holds
100 a per-run lock), so a double click costs nothing extra."""
101 if not config.RUN_RE.match(run_id):
102 raise HTTPException(400,
"invalid run id")
104 return export_cache.get_index(run_id)
105 except FileNotFoundError
as e:
106 raise HTTPException(404, str(e))
107 except RuntimeError
as e:
108 raise HTTPException(500, str(e))
111@app.get("/api/runs/{run_id}/config", response_class=PlainTextResponse)
113 """The config snapshot taken beside this run's DB when the session started.
115 Served verbatim — it is the record of what actually ran, so it is never reformatted
116 or re-serialised. Runs recorded before the snapshot existed simply have no file.
118 if not config.RUN_RE.match(run_id):
119 raise HTTPException(400,
"invalid run id")
120 path = run_config.snapshot_path(run_id)
122 text = path.read_text(encoding=
"utf-8")
124 raise HTTPException(404,
"no config snapshot for this run")
125 return PlainTextResponse(
127 media_type=
"text/plain; charset=utf-8",
128 headers={
"Content-Disposition": f
'inline; filename="{run_id}.toml"'},
132@app.get("/api/runs/{run_id}/series")
135 components: str = Query(...),
136 start: float |
None =
None,
137 end: float |
None =
None,
138 max_points: int = 4000,
139 time_source: str =
"sensor",
141 if not config.RUN_RE.match(run_id):
142 raise HTTPException(400,
"invalid run id")
145 raise HTTPException(400,
"no components requested")
147 export_cache.ensure_exported(run_id)
149 return series.series_json(
150 run_id, comps, start, end,
max(4,
min(max_points, 50000)), src
152 except FileNotFoundError
as e:
153 raise HTTPException(404, str(e))
156@app.get("/api/runs/{run_id}/download")
159 components: str |
None =
None,
160 start: float |
None =
None,
161 end: float |
None =
None,
162 time_source: str =
"sensor",
166 - With `components` (a plot selection): a **wide** CSV — one column per
167 channel aligned on a shared time grid. Bounded by the selection, ideal for
168 analysis of a few channels.
169 - Without `components` (whole run): a **long/tidy** CSV (`time,component,
170 value`) over every primary channel. Streams at constant memory; a wide
171 whole-run frame would blow up to millions of rows × hundreds of columns.
173 if not config.RUN_RE.match(run_id):
174 raise HTTPException(400,
"invalid run id")
176 index = export_cache.get_index(run_id)
179 rows = series.wide_csv_rows(run_id, comps, start, end, src)
180 fname = f
"{run_id}_selection.csv"
182 comps = [c[
"name"]
for c
in index[
"components"]
if c[
"primary"]]
184 raise HTTPException(404,
"no exportable channels")
185 rows = series.long_csv_rows(run_id, comps, start, end, src)
186 fname = f
"{run_id}.csv"
188 rows = series.throttle(rows, config.MAX_DOWNLOAD_BYTES_PER_SEC)
189 return StreamingResponse(
191 media_type=
"text/csv",
192 headers={
"Content-Disposition": f
'attachment; filename="{fname}"'},
196@app.get("/api/runs/{run_id}/video")
198 """The video clips that overlap this run, on the epoch-seconds axis the plot uses.
200 Matched against the run's whole extent on either clock, so switching the plot to
201 DB write time never hides a clip. Needs the index (that is where the extent lives);
202 an un-indexed run has no plot to put video on anyway. `available` is false when no
203 recordings folder is mounted: the page then shows no video panel at all.
205 if not config.RUN_RE.match(run_id):
206 raise HTTPException(400,
"invalid run id")
207 if not video.available():
208 return {
"available":
False,
"clips": []}
209 if not export_cache.is_cached(run_id):
210 raise HTTPException(409,
"run is not indexed yet")
211 index = export_cache.get_index(run_id)
212 lows = [t
for t
in (index.get(
"t_min"), index.get(
"sensor_t_min"))
if t
is not None]
213 highs = [t
for t
in (index.get(
"t_max"), index.get(
"sensor_t_max"))
if t
is not None]
214 if not lows
or not highs:
215 return {
"available":
True,
"clips": []}
216 clips = video.clips_overlapping(
min(lows),
max(highs))
217 return {
"available":
True,
"clips": [c.as_json()
for c
in clips]}
226VIDEO_CACHE_CONTROL =
"private, max-age=86400"
229@app.get("/api/video/{recording}/{stream}.mp4")
231 """One clip's file, with HTTP Range (Starlette's FileResponse answers 206) so the
232 browser can seek without downloading the whole recording."""
233 path = video.clip_file(recording, stream)
235 raise HTTPException(404,
"no such clip")
236 return FileResponse(path, media_type=
"video/mp4", headers={
"Cache-Control": VIDEO_CACHE_CONTROL})
241_dist = Path(__file__).resolve().parent.parent /
"frontend" /
"dist"
243 app.mount(
"/", StaticFiles(directory=str(_dist), html=
True), name=
"static")
api_download(str run_id, str|None components=None, float|None start=None, float|None end=None, str time_source="sensor")
list[str] _parse_components(str|None components)
api_video_file(str recording, str stream)
api_set_description(str run_id, str text=Body(..., embed=True, max_length=4000))
str _time_source(str value)
api_components(str run_id)
api_run_video(str run_id)
api_series(str run_id, str components=Query(...), float|None start=None, float|None end=None, int max_points=4000, str time_source="sensor")