feat: implement in-memory caching for dashboard data and background warmup task

This commit is contained in:
Lorenzo Venerandi
2026-03-10 11:00:22 +01:00
parent 56345974c8
commit 1eb4f54f5c
5 changed files with 145 additions and 21 deletions

32
src/dashboard_cache.py Normal file
View File

@@ -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

View File

@@ -18,6 +18,7 @@ from pydantic import BaseModel
from dependencies import get_db, get_client_ip from dependencies import get_db, get_client_ip
from logger import get_app_logger from logger import get_app_logger
from dashboard_cache import get_cached, is_warm
# Server-side session token store (valid tokens for authenticated sessions) # Server-side session token store (valid tokens for authenticated sessions)
_auth_tokens: set = set() _auth_tokens: set = set()
@@ -249,10 +250,16 @@ async def all_ips(
sort_by: str = Query("total_requests"), sort_by: str = Query("total_requests"),
sort_order: str = Query("desc"), sort_order: str = Query("desc"),
): ):
db = get_db()
page = max(1, page) page = max(1, page)
page_size = min(max(1, page_size), 10000) 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: try:
result = db.get_all_ips_paginated( result = db.get_all_ips_paginated(
page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order page=page, page_size=page_size, sort_by=sort_by, sort_order=sort_order

View File

@@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse
from logger import get_app_logger from logger import get_app_logger
from dependencies import get_db, get_templates from dependencies import get_db, get_templates
from dashboard_cache import get_cached, is_warm
router = APIRouter() router = APIRouter()
@@ -17,17 +18,19 @@ router = APIRouter()
@router.get("") @router.get("")
@router.get("/") @router.get("/")
async def dashboard_page(request: Request): async def dashboard_page(request: Request):
db = get_db()
config = request.app.state.config config = request.app.state.config
dashboard_path = "/" + config.dashboard_secret_path.lstrip("/") dashboard_path = "/" + config.dashboard_secret_path.lstrip("/")
# Get initial data for server-rendered sections # Serve from pre-computed cache when available, fall back to live queries
stats = db.get_dashboard_counts() if is_warm():
suspicious = db.get_recent_suspicious(limit=10) stats = get_cached("stats")
suspicious = get_cached("suspicious")
# Get credential count for the stats card else:
cred_result = db.get_credentials_paginated(page=1, page_size=1) db = get_db()
stats["credential_count"] = cred_result["pagination"]["total"] 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() templates = get_templates()
return templates.TemplateResponse( return templates.TemplateResponse(

View File

@@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse
from dependencies import get_db, get_templates from dependencies import get_db, get_templates
from routes.api import verify_auth from routes.api import verify_auth
from dashboard_cache import get_cached, is_warm
router = APIRouter() router = APIRouter()
@@ -58,10 +59,15 @@ async def htmx_top_ips(
sort_by: str = Query("count"), sort_by: str = Query("count"),
sort_order: str = Query("desc"), sort_order: str = Query("desc"),
): ):
db = get_db() # Serve from cache on default first-page request
result = db.get_top_ips_paginated( cached = get_cached("top_ips") if (page == 1 and sort_by == "count" and sort_order == "desc" and is_warm()) else None
page=max(1, page), page_size=8, sort_by=sort_by, sort_order=sort_order 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() templates = get_templates()
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -87,10 +93,14 @@ async def htmx_top_paths(
sort_by: str = Query("count"), sort_by: str = Query("count"),
sort_order: str = Query("desc"), sort_order: str = Query("desc"),
): ):
db = get_db() cached = get_cached("top_paths") if (page == 1 and sort_by == "count" and sort_order == "desc" and is_warm()) else None
result = db.get_top_paths_paginated( if cached:
page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order 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() templates = get_templates()
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -116,10 +126,14 @@ async def htmx_top_ua(
sort_by: str = Query("count"), sort_by: str = Query("count"),
sort_order: str = Query("desc"), sort_order: str = Query("desc"),
): ):
db = get_db() cached = get_cached("top_ua") if (page == 1 and sort_by == "count" and sort_order == "desc" and is_warm()) else None
result = db.get_top_user_agents_paginated( if cached:
page=max(1, page), page_size=5, sort_by=sort_by, sort_order=sort_order 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() templates = get_templates()
return templates.TemplateResponse( return templates.TemplateResponse(

View File

@@ -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}")