diff --git a/src/dashboard_cache.py b/src/dashboard_cache.py new file mode 100644 index 0000000..c0dcd7f --- /dev/null +++ b/src/dashboard_cache.py @@ -0,0 +1,32 @@ +""" +In-memory cache for dashboard Overview data. + +A background task periodically refreshes this cache so the dashboard +serves pre-computed data instantly instead of hitting SQLite cold. + +Memory footprint is fixed — each key is overwritten on every refresh. +""" + +import threading +from typing import Any, Dict, Optional + +_lock = threading.Lock() +_cache: Dict[str, Any] = {} + + +def get_cached(key: str) -> Optional[Any]: + """Get a value from the dashboard cache.""" + with _lock: + return _cache.get(key) + + +def set_cached(key: str, value: Any) -> None: + """Set a value in the dashboard cache.""" + with _lock: + _cache[key] = value + + +def is_warm() -> bool: + """Check if the cache has been populated at least once.""" + with _lock: + return "stats" in _cache diff --git a/src/routes/api.py b/src/routes/api.py index 11ee8ce..64e05b7 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from dependencies import get_db, get_client_ip from logger import get_app_logger +from dashboard_cache import get_cached, is_warm # Server-side session token store (valid tokens for authenticated sessions) _auth_tokens: set = set() @@ -249,10 +250,16 @@ async def all_ips( sort_by: str = Query("total_requests"), sort_order: str = Query("desc"), ): - db = get_db() page = max(1, page) page_size = min(max(1, page_size), 10000) + # Serve from cache on default map request (top 100 IPs) + if page == 1 and page_size == 100 and sort_by == "total_requests" and sort_order == "desc" and is_warm(): + cached = get_cached("map_ips") + if cached: + return JSONResponse(content=cached, headers=_no_cache_headers()) + + db = get_db() try: result = db.get_all_ips_paginated( page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order diff --git a/src/routes/dashboard.py b/src/routes/dashboard.py index 081336c..37f9d51 100644 --- a/src/routes/dashboard.py +++ b/src/routes/dashboard.py @@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse from logger import get_app_logger from dependencies import get_db, get_templates +from dashboard_cache import get_cached, is_warm router = APIRouter() @@ -17,17 +18,19 @@ router = APIRouter() @router.get("") @router.get("/") async def dashboard_page(request: Request): - db = get_db() config = request.app.state.config dashboard_path = "/" + config.dashboard_secret_path.lstrip("/") - # Get initial data for server-rendered sections - stats = db.get_dashboard_counts() - suspicious = db.get_recent_suspicious(limit=10) - - # Get credential count for the stats card - cred_result = db.get_credentials_paginated(page=1, page_size=1) - stats["credential_count"] = cred_result["pagination"]["total"] + # Serve from pre-computed cache when available, fall back to live queries + if is_warm(): + stats = get_cached("stats") + suspicious = get_cached("suspicious") + else: + db = get_db() + stats = db.get_dashboard_counts() + suspicious = db.get_recent_suspicious(limit=10) + cred_result = db.get_credentials_paginated(page=1, page_size=1) + stats["credential_count"] = cred_result["pagination"]["total"] templates = get_templates() return templates.TemplateResponse( diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 98373e7..2d7e20d 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse from dependencies import get_db, get_templates from routes.api import verify_auth +from dashboard_cache import get_cached, is_warm router = APIRouter() @@ -58,10 +59,15 @@ async def htmx_top_ips( sort_by: str = Query("count"), sort_order: str = Query("desc"), ): - db = get_db() - result = db.get_top_ips_paginated( - page=max(1, page), page_size=8, sort_by=sort_by, sort_order=sort_order - ) + # Serve from cache on default first-page request + cached = get_cached("top_ips") if (page == 1 and sort_by == "count" and sort_order == "desc" and is_warm()) else None + if cached: + result = cached + else: + db = get_db() + result = db.get_top_ips_paginated( + page=max(1, page), page_size=8, sort_by=sort_by, sort_order=sort_order + ) templates = get_templates() return templates.TemplateResponse( @@ -87,10 +93,14 @@ async def htmx_top_paths( sort_by: str = Query("count"), sort_order: str = Query("desc"), ): - db = get_db() - result = db.get_top_paths_paginated( - page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order - ) + cached = get_cached("top_paths") if (page == 1 and sort_by == "count" and sort_order == "desc" and is_warm()) else None + if cached: + result = cached + else: + db = get_db() + result = db.get_top_paths_paginated( + page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order + ) templates = get_templates() return templates.TemplateResponse( @@ -116,10 +126,14 @@ async def htmx_top_ua( sort_by: str = Query("count"), sort_order: str = Query("desc"), ): - db = get_db() - result = db.get_top_user_agents_paginated( - page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order - ) + cached = get_cached("top_ua") if (page == 1 and sort_by == "count" and sort_order == "desc" and is_warm()) else None + if cached: + result = cached + else: + db = get_db() + result = db.get_top_user_agents_paginated( + page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order + ) templates = get_templates() return templates.TemplateResponse( diff --git a/src/tasks/dashboard_warmup.py b/src/tasks/dashboard_warmup.py new file mode 100644 index 0000000..3734864 --- /dev/null +++ b/src/tasks/dashboard_warmup.py @@ -0,0 +1,68 @@ +# tasks/dashboard_warmup.py + +""" +Pre-computes all Overview tab data and stores it in the in-memory cache. +This keeps SQLite page buffers warm and lets the dashboard respond instantly. +""" + +from logger import get_app_logger +from database import get_database +from dashboard_cache import set_cached + +app_logger = get_app_logger() + +# ---------------------- +# TASK CONFIG +# ---------------------- +TASK_CONFIG = { + "name": "dashboard-warmup", + "cron": "*/1 * * * *", + "enabled": True, + "run_when_loaded": True, +} + + +# ---------------------- +# TASK LOGIC +# ---------------------- +def main(): + """ + Refresh the in-memory dashboard cache with current Overview data. + TasksMaster will call this function based on the cron schedule. + """ + task_name = TASK_CONFIG.get("name") + app_logger.info(f"[Background Task] {task_name} starting...") + + try: + db = get_database() + + # --- Server-rendered data (stats cards + suspicious table) --- + stats = db.get_dashboard_counts() + + cred_result = db.get_credentials_paginated(page=1, page_size=1) + stats["credential_count"] = cred_result["pagination"]["total"] + + suspicious = db.get_recent_suspicious(limit=10) + + # --- HTMX Overview tables (first page, default sort) --- + top_ips = db.get_top_ips_paginated(page=1, page_size=8) + top_ua = db.get_top_user_agents_paginated(page=1, page_size=5) + top_paths = db.get_top_paths_paginated(page=1, page_size=5) + + # --- Map data (default: top 100 IPs by total_requests) --- + map_ips = db.get_all_ips_paginated( + page=1, page_size=100, sort_by="total_requests", sort_order="desc" + ) + + # Store everything in the cache (overwrites previous values) + set_cached("stats", stats) + set_cached("suspicious", suspicious) + set_cached("top_ips", top_ips) + set_cached("top_ua", top_ua) + set_cached("top_paths", top_paths) + set_cached("map_ips", map_ips) + + app_logger.info(f"[Background Task] {task_name} cache refreshed successfully.") + + except Exception as e: + app_logger.error(f"[Background Task] {task_name} failed: {e}")