From 537ed3eab3ba71965d1c54d1830c41a52ca5357b Mon Sep 17 00:00:00 2001 From: Ivan Kashkan Date: Fri, 21 Aug 2026 10:04:02 +0200 Subject: [PATCH] Add in-app experiment file downloads (Downloads menu + ZIP endpoint) New GET /api/experiments/{id}/download zips an experiment's whole folder (images, metadata.json, saved config XML) and serves it as an attachment. Built to a temp file on disk rather than in memory -- a multi-day run can accumulate hundreds of JPEGs, and holding the whole archive in an io.BytesIO() risks real memory pressure on a Pi with as little as 2GB RAM. New Downloads screen (TopNav, modeled on ImportConfigMenu) lists the current researcher's own experiments -- filtered client-side by the same getUsername() value already written into each experiment folder name -- with a per-experiment "Download ZIP" link. This box has no auth, so the filter is a UI convenience (see "my experiments" vs "everyone's"), not access control; documented as such in the code and README. Co-Authored-By: Claude Sonnet 5 --- README.md | 23 +++++++ back/rapidboxes/api/experiments.py | 48 ++++++++++++- back/tests/test_api.py | 42 +++++++++++ .../client/components/DownloadsMenu.tsx | 69 +++++++++++++++++++ .../client/components/TopNav.tsx | 11 ++- .../client/lib/api.ts | 4 ++ 6 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 front/plant-imaging-controller-faa-main/client/components/DownloadsMenu.tsx diff --git a/README.md b/README.md index 0a6a2c8..4e92849 100644 --- a/README.md +++ b/README.md @@ -303,6 +303,8 @@ From left to right: This does **not** stop the backend service. - **Import**: opens the import menu and lets the user load a previous experiment configuration. +- **Downloads**: opens the downloads menu and lets the user grab a ZIP of their + own past experiment folders (images + metadata + saved config). - **User**: opens the on-screen keyboard to change the saved researcher name. - **Gallery**: opens the image gallery. - **Live**: opens the live camera preview. @@ -323,6 +325,25 @@ The import menu lists previous experiments from history. - a Tropism config opens the **Tropism** screen - The **X** button closes the import menu without loading anything. +### Downloads menu + +The downloads menu lists previous experiments from history, filtered to the +ones whose saved `username` matches the current researcher name (the same +name shown on the **User** button, and the same one baked into each +experiment's folder name). This box has no login/auth, so the filter is a +convenience for finding your own runs quickly, not an access-control +boundary — any experiment folder is reachable by anyone on the LAN who knows +its ID. + +- Each row shows the experiment name, start date, and image count, plus a + **Download ZIP** button. +- Tapping **Download ZIP** downloads a `.zip` of that experiment's entire + folder — every captured image, `metadata.json`, and the saved `.xml` + protocol config — via `GET /api/experiments/{id}/download`. The browser + saves it like any other file download; no separate tool (SSH, Samba, etc.) + is required. +- The **X** button closes the downloads menu. + ### Settings menu The settings menu has two tabs: @@ -501,3 +522,5 @@ Compared with the old single-purpose UI flow, the current system now includes: off the capture path, so a dead share can never stall or fail a running experiment. The share password is session-only and never written to disk, and the UI says so plainly both while it is typed and after a restart clears it. +- a **downloads menu** for grabbing a ZIP of your own experiment files + (images + metadata + config) straight from the browser, no SSH/Samba needed diff --git a/back/rapidboxes/api/experiments.py b/back/rapidboxes/api/experiments.py index 5cd472a..4164e57 100644 --- a/back/rapidboxes/api/experiments.py +++ b/back/rapidboxes/api/experiments.py @@ -1,9 +1,13 @@ """Experiment lifecycle + history.""" from __future__ import annotations +import os +import tempfile +import zipfile from typing import List -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from fastapi.responses import FileResponse from .. import config_xml from ..models import ExperimentConfig, ExperimentStatus, SavedExperimentConfig, StartResponse @@ -85,3 +89,45 @@ async def get_config(experiment_id: str, state: AppState = Depends(get_state)): return config_xml.parse(data) except Exception: raise HTTPException(500, "could not parse saved config") + + +@router.get("/{experiment_id}/download") +async def download_experiment( + experiment_id: str, background_tasks: BackgroundTasks, state: AppState = Depends(get_state) +): + """Zip an experiment's whole folder (images + metadata.json + saved config + XML) and hand it back as a downloadable attachment. + + An experiment can accumulate hundreds of JPEGs over a multi-day run, and + this runs on a Pi with as little as 2GB of RAM. Building the archive in an + `io.BytesIO()` would hold the *entire* zip in memory at once (easily + hundreds of MB), which risks real memory pressure -- possibly enough to + OOM a box that's unattended and mid-protocol on another experiment. There + is plenty of disk under storage_root by comparison, so instead we stream + the archive to a temp file on disk (bounded, constant memory regardless of + experiment size) and hand that off to FileResponse, which streams it to + the client in chunks. The temp file is removed by a background task once + the response has been sent. + """ + exp = state.storage.get_experiment(experiment_id) + if exp is None: + raise HTTPException(404, "experiment not found") + + fd, tmp_path = tempfile.mkstemp(suffix=".zip", prefix=f"{exp.experiment_id}-") + os.close(fd) + try: + with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf: + for f in sorted(exp.path.rglob("*")): + if f.is_file(): + zf.write(f, arcname=f.relative_to(exp.path)) + except Exception: + os.remove(tmp_path) + raise + + background_tasks.add_task(os.remove, tmp_path) + return FileResponse( + tmp_path, + media_type="application/zip", + filename=f"{exp.experiment_id}.zip", + background=background_tasks, + ) diff --git a/back/tests/test_api.py b/back/tests/test_api.py index f0b8d55..1107d71 100644 --- a/back/tests/test_api.py +++ b/back/tests/test_api.py @@ -1,6 +1,8 @@ """FastAPI integration tests with simulated hardware.""" from __future__ import annotations +import io +import zipfile from datetime import datetime, timezone from pathlib import Path @@ -318,6 +320,46 @@ async def test_abort_stops_and_deletes_experiment(client: AsyncClient, app_confi assert (await client.get("/api/experiments/history")).json() == [] +@pytest.mark.asyncio +async def test_download_experiment_zip_contains_all_files(app_config: AppConfig): + """The download endpoint should zip everything under the experiment's + folder (images, metadata.json, saved config xml) and offer it as a + downloadable attachment, without loading the whole archive into memory + at once (see the comment on download_experiment for why).""" + app = create_app(app_config) + async with app.router.lifespan_context(app): + storage = app.state.app.storage + exp = storage.create_experiment("alice", "zip-test") + (exp.path / "dark_00000.jpg").write_bytes(b"fake-jpeg-bytes-1") + (exp.path / "bending_00000.jpg").write_bytes(b"fake-jpeg-bytes-2") + exp.write_metadata({"experimentName": "zip-test", "username": "alice", "imagesCaptured": 2}) + exp.write_config_xml(b"", "zip-test") + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + res = await ac.get(f"/api/experiments/{exp.experiment_id}/download") + + assert res.status_code == 200 + assert res.headers["content-type"] == "application/zip" + assert f'filename="{exp.experiment_id}.zip"' in res.headers["content-disposition"] + assert "attachment" in res.headers["content-disposition"] + + with zipfile.ZipFile(io.BytesIO(res.content)) as zf: + assert zf.testzip() is None # no corrupt members + names = set(zf.namelist()) + assert "dark_00000.jpg" in names + assert "bending_00000.jpg" in names + assert "metadata.json" in names + assert "zip-test.xml" in names + assert zf.read("dark_00000.jpg") == b"fake-jpeg-bytes-1" + + +@pytest.mark.asyncio +async def test_download_experiment_404_for_missing_experiment(client: AsyncClient): + res = await client.get("/api/experiments/does-not-exist/download") + assert res.status_code == 404 + + @pytest.mark.asyncio async def test_cannot_change_settings_while_running(client: AsyncClient, app_config: AppConfig): config = TropismConfig( diff --git a/front/plant-imaging-controller-faa-main/client/components/DownloadsMenu.tsx b/front/plant-imaging-controller-faa-main/client/components/DownloadsMenu.tsx new file mode 100644 index 0000000..f3b9271 --- /dev/null +++ b/front/plant-imaging-controller-faa-main/client/components/DownloadsMenu.tsx @@ -0,0 +1,69 @@ +import { useQuery } from "@tanstack/react-query"; +import { FolderDown, X } from "lucide-react"; +import { api } from "@/lib/api"; +import { getUsername } from "@/lib/session"; + +export default function DownloadsMenu({ onClose }: { onClose: () => void }) { + const { data, isLoading } = useQuery({ + queryKey: ["history"], + queryFn: () => api.history(), + }); + const username = getUsername(); + // No server-side auth on this box -- this filter is purely a UI convenience + // so a researcher sees "my experiments" rather than everyone's. + const entries = (data ?? []).filter((entry) => entry.username === username); + + return ( +
+
+ + My Experiment Files + + +
+ +
+ {isLoading ? ( +
+ Loading… +
+ ) : entries.length === 0 ? ( +

+ No experiments found for "{username}" yet. +

+ ) : ( +
+ {entries.map((entry) => ( +
+
+
+ {entry.name ?? entry.id} +
+
+ {entry.startedAt ?? "unknown date"} · {entry.imagesCaptured} images +
+
+ + + Download ZIP + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/front/plant-imaging-controller-faa-main/client/components/TopNav.tsx b/front/plant-imaging-controller-faa-main/client/components/TopNav.tsx index 0d8228a..10037a6 100644 --- a/front/plant-imaging-controller-faa-main/client/components/TopNav.tsx +++ b/front/plant-imaging-controller-faa-main/client/components/TopNav.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { X, User, Folder, Radio, Settings, Download } from "lucide-react"; +import { X, User, Folder, Radio, Settings, Download, FolderDown } from "lucide-react"; import { toast } from "sonner"; import OnScreenKeyboard from "@/components/OnScreenKeyboard"; import SettingsMenu from "@/components/SettingsMenu"; import ImportConfigMenu from "@/components/ImportConfigMenu"; +import DownloadsMenu from "@/components/DownloadsMenu"; import RunningExperimentButton from "@/components/RunningExperimentButton"; import { getUsername, setUsername } from "@/lib/session"; import { useSystemInfo } from "@/hooks/useSystemInfo"; @@ -16,6 +17,7 @@ export default function TopNav() { const [editingUser, setEditingUser] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); + const [downloadsOpen, setDownloadsOpen] = useState(false); const [username, setUser] = useState(getUsername()); const [system, setSystem] = useSystemInfo(); const cameraAvailable = system?.cameraAvailable ?? true; @@ -76,6 +78,11 @@ export default function TopNav() { Import + +