diff --git a/app/api/__init__.py b/app/api/__init__.py index b483248a..97f13099 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -15,6 +15,7 @@ from app.api.logs import router as logs_router from app.api.onedrive import router as onedrive_router from app.api.openai import router as openai_router from app.api.process import router as process_router +from app.api.queue import router as queue_router from app.api.search import router as search_router from app.api.settings import router as settings_router from app.api.url_upload import router as url_upload_router @@ -42,3 +43,4 @@ router.include_router(logs_router) router.include_router(settings_router) router.include_router(url_upload_router) router.include_router(search_router) +router.include_router(queue_router) diff --git a/app/api/queue.py b/app/api/queue.py new file mode 100644 index 00000000..fdc851af --- /dev/null +++ b/app/api/queue.py @@ -0,0 +1,270 @@ +""" +Queue monitoring API endpoints. + +Provides endpoints to query Celery/Redis queue statistics and +database-level processing status for document pipeline visibility. +""" + +import logging +from typing import Any + +import redis +from fastapi import APIRouter, Depends +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.config import settings +from app.database import get_db +from app.models import FileProcessingStep, FileRecord + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/queue", tags=["queue"]) + +# Constants +CELERY_INSPECT_TIMEOUT = 2.0 +MAX_ARGS_DISPLAY_LENGTH = 200 + + +def _get_redis_queue_length(redis_client: redis.Redis, queue_name: str) -> int: + """Get the number of messages in a Redis-backed Celery queue. + + Args: + redis_client: Connected Redis client instance. + queue_name: Name of the Celery queue to inspect. + + Returns: + Number of messages (tasks) waiting in the queue. + """ + try: + return redis_client.llen(queue_name) + except Exception: + logger.debug(f"Could not read queue length for '{queue_name}'") + return 0 + + +def _get_celery_inspect_stats() -> dict[str, Any]: + """Query the Celery inspect API for active, reserved, and scheduled tasks. + + Returns: + Dictionary with active, reserved, and scheduled task summaries. + """ + from app.celery_app import celery + + result: dict[str, Any] = { + "active": [], + "reserved": [], + "scheduled": [], + "workers_online": 0, + } + + try: + inspector = celery.control.inspect(timeout=CELERY_INSPECT_TIMEOUT) + + active = inspector.active() or {} + reserved = inspector.reserved() or {} + scheduled = inspector.scheduled() or {} + + result["workers_online"] = len(active) + + for _worker, tasks in active.items(): + for task in tasks: + result["active"].append( + { + "id": task.get("id", ""), + "name": task.get("name", "unknown"), + "args": str(task.get("args", []))[:MAX_ARGS_DISPLAY_LENGTH], + "started": task.get("time_start"), + } + ) + + for _worker, tasks in reserved.items(): + for task in tasks: + result["reserved"].append( + { + "id": task.get("id", ""), + "name": task.get("name", "unknown"), + "args": str(task.get("args", []))[:MAX_ARGS_DISPLAY_LENGTH], + } + ) + + for _worker, tasks in scheduled.items(): + for task in tasks: + req = task.get("request", {}) + result["scheduled"].append( + { + "id": req.get("id", ""), + "name": req.get("name", "unknown"), + "eta": task.get("eta"), + } + ) + except Exception as exc: + logger.warning(f"Celery inspect failed (workers may be offline): {exc}") + + return result + + +def _get_db_processing_summary(db: Session) -> dict[str, Any]: + """Query the database for a summary of file processing states. + + Args: + db: SQLAlchemy database session. + + Returns: + Dictionary with counts of files by processing state. + """ + try: + total_files = db.query(func.count(FileRecord.id)).scalar() or 0 + + # Count files with at least one in_progress step + processing_count = ( + db.query(func.count(func.distinct(FileProcessingStep.file_id))) + .filter(FileProcessingStep.status == "in_progress") + .scalar() + or 0 + ) + + # Count files with at least one failure and no in_progress + failed_subq = ( + db.query(FileProcessingStep.file_id).filter(FileProcessingStep.status == "failure").distinct().subquery() + ) + in_progress_subq = ( + db.query(FileProcessingStep.file_id) + .filter(FileProcessingStep.status == "in_progress") + .distinct() + .subquery() + ) + failed_count = ( + db.query(func.count(func.distinct(failed_subq.c.file_id))) + .filter(~failed_subq.c.file_id.in_(db.query(in_progress_subq.c.file_id))) + .scalar() + or 0 + ) + + # Count files that have steps and all steps are success/skipped + all_step_files = db.query(FileProcessingStep.file_id).distinct().subquery() + # Files with any non-terminal step + non_terminal = ( + db.query(FileProcessingStep.file_id) + .filter(FileProcessingStep.status.in_(["in_progress", "pending", "failure"])) + .distinct() + .subquery() + ) + completed_count = ( + db.query(func.count(func.distinct(all_step_files.c.file_id))) + .filter(~all_step_files.c.file_id.in_(db.query(non_terminal.c.file_id))) + .scalar() + or 0 + ) + + # Files with no processing steps at all + files_with_steps = db.query(FileProcessingStep.file_id).distinct().subquery() + pending_count = ( + db.query(func.count(FileRecord.id)) + .filter(~FileRecord.id.in_(db.query(files_with_steps.c.file_id))) + .filter(FileRecord.is_duplicate.is_(False)) + .scalar() + or 0 + ) + + # Recent files being processed (last 20 in_progress or pending) + recent_processing = ( + db.query(FileRecord.id, FileRecord.original_filename, FileProcessingStep.step_name) + .join(FileProcessingStep, FileRecord.id == FileProcessingStep.file_id) + .filter(FileProcessingStep.status == "in_progress") + .order_by(FileProcessingStep.updated_at.desc()) + .limit(20) + .all() + ) + + recent_list = [ + {"file_id": r[0], "filename": r[1] or f"File #{r[0]}", "current_step": r[2]} for r in recent_processing + ] + + return { + "total_files": total_files, + "processing": processing_count, + "failed": failed_count, + "completed": completed_count, + "pending": pending_count, + "recent_processing": recent_list, + } + except Exception as exc: + logger.error(f"Error querying DB processing summary: {exc}") + return { + "total_files": 0, + "processing": 0, + "failed": 0, + "completed": 0, + "pending": 0, + "recent_processing": [], + } + + +@router.get("/stats") +def get_queue_stats(db: Session = Depends(get_db)) -> dict[str, Any]: + """Get comprehensive queue and processing statistics. + + Returns queue lengths from Redis, Celery worker inspection data, + and database-level processing summaries for the document pipeline. + + Returns: + Dictionary containing redis queue info, celery worker info, + and database processing summary. + """ + # 1. Redis queue lengths + queue_lengths: dict[str, int] = {} + try: + redis_client = redis.Redis.from_url(settings.redis_url, decode_responses=True) + for queue_name in ["document_processor", "default", "celery"]: + queue_lengths[queue_name] = _get_redis_queue_length(redis_client, queue_name) + redis_client.close() + except Exception as exc: + logger.warning(f"Could not connect to Redis: {exc}") + + total_queued = sum(queue_lengths.values()) + + # 2. Celery inspect + celery_stats = _get_celery_inspect_stats() + + # 3. DB summary + db_summary = _get_db_processing_summary(db) + + return { + "queues": queue_lengths, + "total_queued": total_queued, + "celery": celery_stats, + "db_summary": db_summary, + } + + +@router.get("/pending-count") +def get_pending_count(db: Session = Depends(get_db)) -> dict[str, int]: + """Get a lightweight count of queued + in-progress items for the files page banner. + + Returns: + Dictionary with total_pending count (queued in Redis + processing in DB). + """ + total_pending = 0 + + # Redis queue lengths + try: + redis_client = redis.Redis.from_url(settings.redis_url, decode_responses=True) + for queue_name in ["document_processor", "default", "celery"]: + total_pending += _get_redis_queue_length(redis_client, queue_name) + redis_client.close() + except Exception: + logger.debug("Could not connect to Redis for pending count") + + # DB in-progress count + try: + processing_count = ( + db.query(func.count(func.distinct(FileProcessingStep.file_id))) + .filter(FileProcessingStep.status == "in_progress") + .scalar() + or 0 + ) + total_pending += processing_count + except Exception: + logger.debug("Could not query DB for processing count") + + return {"total_pending": total_pending} diff --git a/app/views/__init__.py b/app/views/__init__.py index bbc00bab..ed55ff29 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -12,6 +12,7 @@ from app.views.general import router as general_router from app.views.google_drive import router as google_drive_router from app.views.license_routes import router as license_router # Add the license router from app.views.onedrive import router as onedrive_router +from app.views.queue import router as queue_router from app.views.search import router as search_router from app.views.settings import router as settings_router from app.views.status import router as status_router @@ -29,3 +30,4 @@ router.include_router(license_router) # Include the license router router.include_router(settings_router) router.include_router(filemanager_router) router.include_router(search_router) +router.include_router(queue_router) diff --git a/app/views/queue.py b/app/views/queue.py new file mode 100644 index 00000000..993fde4f --- /dev/null +++ b/app/views/queue.py @@ -0,0 +1,32 @@ +""" +Queue monitoring view for the admin dashboard. +""" + +import logging + +from fastapi import Depends, Request +from sqlalchemy.orm import Session + +from app.views.base import APIRouter, get_db, require_login, templates +from app.views.settings import require_admin_access + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/admin/queue") +@require_login +@require_admin_access +async def queue_dashboard(request: Request, db: Session = Depends(get_db)): + """ + Queue monitoring dashboard — admin only. + + Displays Celery/Redis queue statistics and database processing summaries + so administrators can monitor the document processing pipeline. + """ + return templates.TemplateResponse( + "queue_dashboard.html", + { + "request": request, + }, + ) diff --git a/docs/API.md b/docs/API.md index 4255f02b..f0d6f87e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -437,6 +437,57 @@ Errors follow standard HTTP status codes with descriptive messages: } ``` +## Queue Monitoring + +### GET /api/queue/stats + +Get comprehensive queue and processing statistics, including Redis queue lengths, Celery worker inspection data, and database-level processing summaries. + +**Authentication:** Required + +**Response (200 OK):** +```json +{ + "queues": { + "document_processor": 12, + "default": 0, + "celery": 0 + }, + "total_queued": 12, + "celery": { + "active": [ + {"id": "abc123", "name": "process_document", "args": "[42]", "started": 1700000000} + ], + "reserved": [], + "scheduled": [], + "workers_online": 1 + }, + "db_summary": { + "total_files": 5000, + "processing": 3, + "failed": 1, + "completed": 4900, + "pending": 96, + "recent_processing": [ + {"file_id": 42, "filename": "invoice.pdf", "current_step": "extract_metadata_with_gpt"} + ] + } +} +``` + +### GET /api/queue/pending-count + +Lightweight endpoint returning the total number of queued + in-progress items. Designed for the files page banner indicator. + +**Authentication:** Required + +**Response (200 OK):** +```json +{ + "total_pending": 15 +} +``` + ## Rate Limiting The API implements rate limiting to ensure system stability. If you exceed the limits, you'll receive a `429 Too Many Requests` response. diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 8f1d5921..39c83cb4 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -92,6 +92,23 @@ The **Files** page provides access to all processed documents: 3. Click on any file to view its details 4. Sort the list by any column by clicking on the column header +> **Tip:** When documents are being processed, a blue banner appears at the top of the Files page showing how many items are queued or currently processing. Files will appear in the list once their processing completes. Admins can click "View Queue" in the banner to open the Queue Monitor dashboard. + +## Queue Monitor (Admin) + +The **Queue Monitor** dashboard provides real-time visibility into the document processing pipeline. It is available to admin users under **Admin → Queue Monitor** in the navigation bar. + +The dashboard shows: +- **Queued Tasks** — number of tasks waiting in Redis-backed Celery queues +- **Active Tasks** — tasks currently being executed by Celery workers +- **Files Processing** — files with at least one in-progress processing step +- **Workers Online** — number of connected Celery worker processes +- **Redis Queues** — per-queue breakdown of pending task counts +- **Processing Pipeline** — database-level summary of file states (completed, processing, pending, failed) +- **Recently Processing Files** — the most recent files being actively processed, with links to their detail pages + +The dashboard auto-refreshes every 10 seconds. + ## Searching Documents DocuElevate provides two ways to search your documents: diff --git a/frontend/templates/base.html b/frontend/templates/base.html index ebe09847..75dcb274 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -95,6 +95,9 @@ File Manager + + Queue Monitor + @@ -168,6 +171,9 @@ File Manager + + Queue Monitor + diff --git a/frontend/templates/files.html b/frontend/templates/files.html index cc4e62ae..9c9b13aa 100644 --- a/frontend/templates/files.html +++ b/frontend/templates/files.html @@ -370,6 +370,23 @@
Real-time view of the document processing pipeline and Celery task queues.
+Loading queue statistics…
+Error:
+Queued Tasks
+0
+Active Tasks
+0
+Files Processing
+0
+Workers Online
+0
+| Queue | +Pending | +
|---|---|
| No data | |
| State | +Files | +
|---|---|
| No data | |
| Task | +Arguments | +Task ID | +
|---|---|---|
| No active tasks | ||
| File | +Current Step | +Actions | +
|---|---|---|
| No files currently processing | ||