File: backend/jobs.py
Jobs track burst captures and image stacking so the frontend can:
1. Submit work and get an immediate job ID back (HTTP 202).
- 2. Poll ``GET /api/jobs/{id}`` for progress updates.
+ 2. Poll ``GET /api/jobs/{id}`` for progress updates and log lines.
3. See completed/failed jobs in ``GET /api/jobs``.
Design constraints (Raspberry Pi):
import threading
import time
import uuid
+from collections import deque
from dataclasses import dataclass, field
from enum import Enum
-from typing import Any, Optional
+from typing import Optional
logger = logging.getLogger(__name__)
# How many completed/failed jobs to keep for the frontend to query.
_MAX_HISTORY = 50
+# Max log lines kept per job.
+_MAX_LOG_LINES = 200
+
class JobStatus(str, Enum):
queued = "queued"
class Job:
started_at: Optional[float] = None
finished_at: Optional[float] = None
_cancel: threading.Event = field(default_factory=threading.Event)
+ _log: deque = field(default_factory=lambda: deque(maxlen=_MAX_LOG_LINES))
@property
def cancelled(self) -> bool:
def cancelled(self) -> bool:
def request_cancel(self):
self._cancel.set()
- def to_dict(self) -> dict:
+ def log(self, line: str):
+ """Append a timestamped log line."""
+ ts = time.strftime("%H:%M:%S")
+ self._log.append(f"[{ts}] {line}")
+
+ def to_dict(self, include_log: bool = False) -> dict:
d = {
"id": self.id,
"type": self.type,
def to_dict(self) -> dict:
d["result"] = self.result
if self.error is not None:
d["error"] = self.error
+ if include_log:
+ d["log"] = list(self._log)
return d
def create(self, job_type: str, total: int = 0, message: str = "") -> Job:
total=total,
message=message,
)
+ job.log(f"Job created: {message or job_type}")
with self._lock:
self._jobs[job.id] = job
self._trim_history()
def get(self, job_id: str) -> Optional[Job]:
def list_all(self) -> list[dict]:
with self._lock:
- return [j.to_dict() for j in self._jobs.values()]
+ # Return newest first.
+ return [j.to_dict() for j in reversed(list(self._jobs.values()))]
def start(self, job: Job):
job.status = JobStatus.running
job.started_at = time.time()
+ job.log("Started")
def update_progress(self, job: Job, progress: int, message: str = ""):
job.progress = progress
if message:
job.message = message
+ job.log(message)
def complete(self, job: Job, result: dict):
job.status = JobStatus.completed
job.progress = job.total
job.result = result
job.finished_at = time.time()
- logger.info("Job %s completed in %.1fs", job.id, job.finished_at - (job.started_at or job.created_at))
+ elapsed = job.finished_at - (job.started_at or job.created_at)
+ job.log(f"Completed in {elapsed:.1f}s")
+ logger.info("Job %s completed in %.1fs", job.id, elapsed)
def fail(self, job: Job, error: str):
job.status = JobStatus.failed
job.error = error
job.finished_at = time.time()
+ job.log(f"FAILED: {error}")
logger.error("Job %s failed: %s", job.id, error)
def cancel(self, job: Job):
job.request_cancel()
job.status = JobStatus.cancelled
job.finished_at = time.time()
+ job.log("Cancelled by user")
logger.info("Job %s cancelled", job.id)
def _trim_history(self):
File: backend/main.py
POST /api/galleries/{gallery}/stack – start stacking (returns job)
GET /api/images/{gallery}/{filename} – serve a gallery image
GET /api/jobs – list all jobs
- GET /api/jobs/{job_id} – get job status
+ GET /api/jobs/{job_id} – get job status + log
POST /api/jobs/{job_id}/cancel – cancel a running job
"""
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse, JSONResponse
+from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
_LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
_level = getattr(logging, _LOG_LEVEL, logging.INFO)
-# basicConfig configures the root logger when no handlers exist yet (standalone run).
-# root.setLevel ensures the level is always applied even when uvicorn has already
-# configured the root logger's handlers before this module is imported.
logging.basicConfig(level=_level)
logging.root.setLevel(_level)
logger = logging.getLogger(__name__)
def capture_image(req: CaptureRequest):
async def capture_burst(req: BurstRequest):
"""Start a burst capture as a background job.
- Returns immediately with a job ID. Poll ``GET /api/jobs/{id}``
- for progress and results.
+ Returns 202 immediately with a job ID.
"""
if req.count < 1:
raise HTTPException(status_code=400, detail="count must be >= 1")
- gallery_dir = _gallery_path(req.gallery)
gallery_name = req.gallery
job = jobs.create(
async def capture_burst(req: BurstRequest):
def _run_burst():
jobs.start(job)
try:
+ gallery_dir = _resolve_gallery(gallery_name)
+ if gallery_dir is None:
+ raise ValueError(f"Gallery '{gallery_name}' not found")
+
def on_progress(frame_idx, total, saved_path):
- jobs.update_progress(
- job,
- frame_idx + 1,
- f"Frame {frame_idx + 1}/{total}"
- + (f" saved {saved_path.name}" if saved_path else " FAILED"),
- )
+ msg = (f"Frame {frame_idx + 1}/{total}"
+ + (f" saved {saved_path.name}" if saved_path else " FAILED"))
+ jobs.update_progress(job, frame_idx + 1, msg)
saved = cam.capture_burst(
gallery_dir,
def delete_image(gallery: str, filename: str):
async def stack_gallery_images(gallery: str, req: StackRequest):
"""Start image stacking as a background job.
- Returns immediately with a job ID. Poll ``GET /api/jobs/{id}``
- for progress and results.
+ Returns 202 immediately with a job ID. All validation and processing
+ happens in the background thread so this never blocks.
"""
- gallery_dir = _gallery_path(gallery)
gallery_name = gallery
-
- # Resolve filenames to paths and validate
- paths = []
- for fn in req.images:
- p = gallery_dir / fn
- if not p.exists():
- raise HTTPException(status_code=404, detail=f"Image not found: {fn}")
- paths.append(p)
-
- if len(paths) < 2:
- raise HTTPException(status_code=400, detail="At least 2 images required for stacking")
-
- output_name = req.output_name or f"stacked-{req.mode}-{int(time.time())}.jpg"
+ image_filenames = list(req.images)
mode = req.mode
+ output_name = req.output_name or f"stacked-{mode}-{int(time.time())}.jpg"
job = jobs.create(
"stack",
- total=len(paths),
- message=f"Stacking {len(paths)} images ({mode})",
+ total=len(image_filenames),
+ message=f"Stacking {len(image_filenames)} images ({mode})",
)
def _run_stack():
jobs.start(job)
try:
+ # Validate gallery exists.
+ gallery_dir = _resolve_gallery(gallery_name)
+ if gallery_dir is None:
+ raise ValueError(f"Gallery '{gallery_name}' not found")
+
+ # Validate image files exist.
+ paths = []
+ for fn in image_filenames:
+ p = gallery_dir / fn
+ if not p.exists():
+ raise FileNotFoundError(f"Image not found: {fn}")
+ paths.append(p)
+
+ if len(paths) < 2:
+ raise ValueError("At least 2 images required for stacking")
+
+ job.log(f"Validated {len(paths)} images, starting {mode} stack")
+
def on_progress(processed, total):
jobs.update_progress(
job, processed, f"Processing image {processed}/{total}"
)
result_image = stk.stack_images(paths, mode=mode, on_progress=on_progress)
output_path = gallery_dir / output_name
+ job.log(f"Saving result to {output_name}")
result_image.save(str(output_path), format="JPEG", quality=95)
jobs.complete(job, {
"ok": True,
async def list_jobs():
@app.get("/api/jobs/{job_id}")
async def get_job(job_id: str):
- """Get the status of a specific job."""
+ """Get the status of a specific job including its log."""
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
- return job.to_dict()
+ return job.to_dict(include_log=True)
@app.post("/api/jobs/{job_id}/cancel")
def serve_image(gallery: str, filename: str):
def _gallery_path(name: str) -> Path:
+ """Resolve and validate a gallery name. Raises HTTPException on failure."""
safe = "".join(c for c in name if c.isalnum() or c in "._- ").strip()
if not safe:
raise HTTPException(status_code=400, detail="Invalid gallery name")
def _gallery_path(name: str) -> Path:
return p
+def _resolve_gallery(name: str) -> Optional[Path]:
+ """Resolve a gallery name to a path, returning None if it doesn't exist.
+
+ Unlike _gallery_path this does NOT raise HTTPException – it's safe
+ to call from background threads where there's no request context.
+ """
+ safe = "".join(c for c in name if c.isalnum() or c in "._- ").strip()
+ if not safe:
+ return None
+ p = GALLERY_ROOT / safe
+ return p if p.exists() else None
+
+
def _list_images(directory: Path) -> list[str]:
exts = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".cr2", ".cr3", ".nef", ".arw"}
return sorted(f.name for f in directory.iterdir() if f.suffix.lower() in exts)
File: frontend/src/App.jsx
import { useState } from "react";
import "./App.css";
import { useCameraStatus, useGalleries, useGallery } from "./hooks/useCamera";
+import { useJobs } from "./hooks/useJobs";
import StatusBadge from "./components/StatusBadge";
import ExposureControls from "./components/ExposureControls";
import GalleryManager from "./components/GalleryManager";
import CapturePanel from "./components/CapturePanel";
import StackingPanel from "./components/StackingPanel";
import GalleryViewer from "./components/GalleryViewer";
+import JobsPanel from "./components/JobsPanel";
-const TABS = ["Capture", "Gallery", "Stacking"];
+const TABS = ["Capture", "Gallery", "Stacking", "Jobs"];
export default function App() {
const { status } = useCameraStatus();
const { galleries, refresh: refreshGalleries } = useGalleries();
const [selectedGallery, setSelectedGallery] = useState(null);
const { images, refresh: refreshImages } = useGallery(selectedGallery);
const [tab, setTab] = useState("Capture");
+ const { jobsList, activeJobDetail, hasActiveJobs, cancelJob, refresh: refreshJobs } = useJobs();
const handleCapture = () => {
refreshImages();
refreshGalleries();
+ refreshJobs();
};
return (
export default function App() {
gphoto2 Astro WebUI
</h1>
</div>
- <StatusBadge
- connected={status?.connected ?? false}
- shootingMode={status?.shooting_mode}
- focusMode={status?.focus_mode}
- battery={status?.battery}
- />
+ <div className="flex items-center gap-3">
+ {hasActiveJobs && (
+ <button
+ onClick={() => setTab("Jobs")}
+ className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/20 border border-blue-500/30 text-blue-400 text-xs font-medium animate-pulse hover:bg-blue-500/30 transition-colors"
+ >
+ <span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
+ Job running
+ </button>
+ )}
+ <StatusBadge
+ connected={status?.connected ?? false}
+ shootingMode={status?.shooting_mode}
+ focusMode={status?.focus_mode}
+ battery={status?.battery}
+ />
+ </div>
</header>
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
export default function App() {
<button
key={t}
onClick={() => setTab(t)}
- className={`flex-1 rounded-md py-1.5 text-sm font-medium transition-colors ${
+ className={`flex-1 rounded-md py-1.5 text-sm font-medium transition-colors relative ${
tab === t
? "bg-indigo-600 text-white"
: "text-slate-400 hover:text-slate-200"
}`}
>
{t}
+ {t === "Jobs" && hasActiveJobs && tab !== "Jobs" && (
+ <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
+ )}
</button>
))}
</div>
export default function App() {
onStackComplete={handleCapture}
/>
)}
+ {tab === "Jobs" && (
+ <JobsPanel
+ jobsList={jobsList}
+ activeJobDetail={activeJobDetail}
+ onCancel={cancelJob}
+ />
+ )}
</div>
</div>
</div>