Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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
48 changes: 47 additions & 1 deletion back/rapidboxes/api/experiments.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
)
42 changes: 42 additions & 0 deletions back/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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"<config></config>", "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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-0 z-50 flex flex-col bg-app-bg-primary">
<div className="flex items-center justify-between border-b border-app-border-primary bg-app-bg-secondary px-3 py-2">
<span className="text-[15px] font-bold uppercase tracking-wide text-white">
My Experiment Files
</span>
<button
onClick={onClose}
className="rounded-md p-1.5 text-app-text-secondary transition-colors hover:bg-app-bg-tertiary hover:text-white"
>
<X className="h-[18px] w-[18px]" strokeWidth={1.5} />
</button>
</div>

<div className="flex-1 overflow-y-auto p-2">
{isLoading ? (
<div className="flex h-full items-center justify-center text-sm text-app-text-muted">
Loading…
</div>
) : entries.length === 0 ? (
<p className="mt-12 text-center text-app-text-muted">
No experiments found for "{username}" yet.
</p>
) : (
<div className="flex flex-col gap-1.5">
{entries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between gap-2 rounded-[10px] border border-app-border-primary bg-app-bg-secondary p-2.5"
>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-bold text-white">
{entry.name ?? entry.id}
</div>
<div className="truncate text-[10px] text-app-text-muted">
{entry.startedAt ?? "unknown date"} · {entry.imagesCaptured} images
</div>
</div>
<a
href={api.experimentDownloadUrl(entry.id)}
download
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-app-bg-tertiary px-2.5 py-1.5 text-[11px] font-semibold text-white transition-colors hover:bg-app-border-primary"
>
<FolderDown className="h-[14px] w-[14px]" strokeWidth={1.5} />
Download ZIP
</a>
</div>
))}
</div>
)}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -76,6 +78,11 @@ export default function TopNav() {
<span className="text-white text-center text-[13px] font-semibold leading-5">Import</span>
</button>

<button className={btn} onClick={() => setDownloadsOpen(true)}>
<FolderDown className="w-[18px] h-[18px]" strokeWidth={1.5} />
<span className="text-white text-center text-[13px] font-semibold leading-5">Downloads</span>
</button>

<button className={btn} onClick={() => setEditingUser(true)}>
<User className="w-[18px] h-[18px]" strokeWidth={1.5} />
<span className="text-white text-center text-[13px] font-semibold leading-5">
Expand Down Expand Up @@ -128,6 +135,8 @@ export default function TopNav() {
<ImportConfigMenu onClose={() => setImportOpen(false)} onLoad={handleImportLoad} />
)}

{downloadsOpen && <DownloadsMenu onClose={() => setDownloadsOpen(false)} />}

{editingUser && (
<OnScreenKeyboard
title="Researcher name"
Expand Down
4 changes: 4 additions & 0 deletions front/plant-imaging-controller-faa-main/client/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export const api = {
history: () => jsonFetch<HistoryEntry[]>("/api/experiments/history"),
experimentConfig: (id: string) =>
jsonFetch<SavedExperimentConfig>(`/api/experiments/${id}/config`),
/** URL for the zipped experiment folder (images + metadata + config XML).
* No fetch needed -- an <a href> download or window.location navigation
* lets the browser handle the actual save. */
experimentDownloadUrl: (id: string) => `/api/experiments/${id}/download`,
images: (experimentId?: string) =>
jsonFetch<ImageListResponse>(experimentId ? `/api/images/${experimentId}` : "/api/images"),
settings: () => jsonFetch<DeviceSettings>("/api/settings"),
Expand Down
Loading