Merge pull request #34 from nerdymark/claude/claude-md-mmikl14iywxsip5l-EbCmh A...

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>
Read more...