diff --git a/app/api/__init__.py b/app/api/__init__.py index b483248a..ec050431 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -17,6 +17,7 @@ from app.api.openai import router as openai_router from app.api.process import router as process_router from app.api.search import router as search_router from app.api.settings import router as settings_router +from app.api.queue import router as queue_router from app.api.url_upload import router as url_upload_router # Import all the individual routers @@ -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..a2677678 --- /dev/null +++ b/app/api/queue.py @@ -0,0 +1,266 @@ +""" +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"]) + + +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=2.0) + + 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", []))[:200], + "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", []))[:200], + } + ) + + 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/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 | ||