From c4d873149b718d8cb917c7b73bea91498d0a4fb9 Mon Sep 17 00:00:00 2001 From: Jens Date: Sat, 22 Aug 2026 22:18:08 +0200 Subject: [PATCH] page the analysis run listings Detection and segmentation run listings returned every run a project had ever produced. Runs accumulate with every analysis while the panel only ever draws the recent ones, so the response grew without bound for no benefit. Both take limit and offset now and report total, limit, offset and truncated, matching the result listings. Co-Authored-By: Claude Opus 5 --- backend/app/api/routes/detection.py | 8 +++++++- backend/app/api/routes/segmentation.py | 8 +++++++- backend/app/schemas/detection.py | 4 ++++ backend/app/schemas/segmentation.py | 4 ++++ backend/app/services/detection_service.py | 14 +++++++++++++- backend/app/services/segmentation_service.py | 13 ++++++++++++- frontend/src/types.ts | 8 ++++++++ 7 files changed, 55 insertions(+), 4 deletions(-) diff --git a/backend/app/api/routes/detection.py b/backend/app/api/routes/detection.py index 038f77c6..c61e0915 100644 --- a/backend/app/api/routes/detection.py +++ b/backend/app/api/routes/detection.py @@ -102,9 +102,15 @@ def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) def list_detection_runs( project_id: UUID | None = None, dataset_id: UUID | None = None, + limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000), + offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: - return envelope(DetectionService.list_runs(db, project_id=project_id, dataset_id=dataset_id).model_dump()) + return envelope( + DetectionService.list_runs( + db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset + ).model_dump() + ) @router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead]) diff --git a/backend/app/api/routes/segmentation.py b/backend/app/api/routes/segmentation.py index 9846b5d8..aba33bb7 100644 --- a/backend/app/api/routes/segmentation.py +++ b/backend/app/api/routes/segmentation.py @@ -73,9 +73,15 @@ def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(ge def list_segmentation_runs( project_id: UUID | None = None, dataset_id: UUID | None = None, + limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000), + offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: - return envelope(SegmentationService.list_runs(db, project_id=project_id, dataset_id=dataset_id).model_dump()) + return envelope( + SegmentationService.list_runs( + db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset + ).model_dump() + ) @router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead]) diff --git a/backend/app/schemas/detection.py b/backend/app/schemas/detection.py index 4b717397..add75ca0 100644 --- a/backend/app/schemas/detection.py +++ b/backend/app/schemas/detection.py @@ -132,7 +132,11 @@ class DetectionRunRead(BaseModel): class DetectionRunListResponse(BaseModel): items: list[DetectionRunRead] + # ``total`` counts every run; ``items`` is the most recent page of them. total: int + limit: int | None = None + offset: int = 0 + truncated: bool = False class DetectionRead(BaseModel): diff --git a/backend/app/schemas/segmentation.py b/backend/app/schemas/segmentation.py index f82415ac..42e5f3ce 100644 --- a/backend/app/schemas/segmentation.py +++ b/backend/app/schemas/segmentation.py @@ -71,7 +71,11 @@ class SegmentationRunRead(BaseModel): class SegmentationRunListResponse(BaseModel): items: list[SegmentationRunRead] + # ``total`` counts every run; ``items`` is the most recent page of them. total: int + limit: int | None = None + offset: int = 0 + truncated: bool = False class SegmentationRead(BaseModel): diff --git a/backend/app/services/detection_service.py b/backend/app/services/detection_service.py index 877364c0..7b6000f5 100644 --- a/backend/app/services/detection_service.py +++ b/backend/app/services/detection_service.py @@ -281,6 +281,8 @@ class DetectionService: *, project_id: uuid.UUID | None = None, dataset_id: uuid.UUID | None = None, + limit: int | None = None, + offset: int = 0, ) -> DetectionRunListResponse: query = db.query(AnalysisRun).filter(AnalysisRun.analysis_type == "detection") if project_id is not None: @@ -288,7 +290,16 @@ class DetectionService: if dataset_id is not None: query = query.filter(AnalysisRun.dataset_id == dataset_id) rows = query.order_by(AnalysisRun.created_at.desc()).all() - return DetectionRunListResponse(items=[DetectionRunRead.model_validate(row) for row in rows], total=len(rows)) + # Runs accumulate with every analysis; the panel draws the recent ones. + resolved_limit = DetectionService.DEFAULT_RUN_LIST_LIMIT if limit is None else int(limit) + page, total, truncated = DetectionService.paginate(rows, limit=resolved_limit, offset=offset) + return DetectionRunListResponse( + items=[DetectionRunRead.model_validate(row) for row in page], + total=total, + limit=resolved_limit, + offset=max(0, int(offset)), + truncated=truncated, + ) @staticmethod def list_detections( @@ -774,6 +785,7 @@ class DetectionService: # A regional run holds tens of thousands of detections; the results table # and the map overlay both read them after every run. DEFAULT_RESULT_LIMIT = 2_000 + DEFAULT_RUN_LIST_LIMIT = 200 @staticmethod def paginate(rows: list[Any], *, limit: int, offset: int) -> tuple[list[Any], int, bool]: diff --git a/backend/app/services/segmentation_service.py b/backend/app/services/segmentation_service.py index 22008b18..be3052a9 100644 --- a/backend/app/services/segmentation_service.py +++ b/backend/app/services/segmentation_service.py @@ -244,6 +244,8 @@ class SegmentationService: *, project_id: uuid.UUID | None = None, dataset_id: uuid.UUID | None = None, + limit: int | None = None, + offset: int = 0, ) -> SegmentationRunListResponse: query = db.query(AnalysisRun).filter(AnalysisRun.analysis_type == "segmentation") if project_id is not None: @@ -251,7 +253,16 @@ class SegmentationService: if dataset_id is not None: query = query.filter(AnalysisRun.dataset_id == dataset_id) rows = query.order_by(AnalysisRun.created_at.desc()).all() - return SegmentationRunListResponse(items=[SegmentationRunRead.model_validate(row) for row in rows], total=len(rows)) + # Runs accumulate with every analysis; the panel draws the recent ones. + resolved_limit = DetectionService.DEFAULT_RUN_LIST_LIMIT if limit is None else int(limit) + page, total, truncated = DetectionService.paginate(rows, limit=resolved_limit, offset=offset) + return SegmentationRunListResponse( + items=[SegmentationRunRead.model_validate(row) for row in page], + total=total, + limit=resolved_limit, + offset=max(0, int(offset)), + truncated=truncated, + ) @staticmethod def list_segmentations( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index aba100bd..033a7196 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1397,8 +1397,12 @@ export interface DetectionRunRead { } export interface DetectionRunListResponse { + /** The most recent page; `total` counts every run. */ items: DetectionRunRead[] total: number + limit?: number | null + offset?: number + truncated?: boolean } export interface DetectionRead { @@ -1582,8 +1586,12 @@ export interface SegmentationRunRead { } export interface SegmentationRunListResponse { + /** The most recent page; `total` counts every run. */ items: SegmentationRunRead[] total: number + limit?: number | null + offset?: number + truncated?: boolean } export interface SegmentationRead {