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 {