# backend/admin.py # Admin Dashboard endpoints — system stats, audit logs, backup stats, # and Server-Sent Events stream for real-time widgets. # # All endpoints protected with require_admin. import asyncio import json import logging import os import shutil import time from datetime import datetime, timezone from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, Query from fastapi.responses import StreamingResponse from backend.audit import AUDIT_LOG_FILE, get_recent_entries from backend.auth.middleware import require_admin from backend.indexer import get_vault_data, index logger = logging.getLogger("obsigate.admin") router = APIRouter(prefix="/api/admin", tags=["admin"]) # Server start time — set at module import. Approximates uptime even # if used before lifespan startup fully runs. _SERVER_START_TIME = time.time() def _server_uptime_seconds() -> float: return time.time() - _SERVER_START_TIME def _format_timestamp_for_stream(payload: dict) -> str: """Serialize a payload as an SSE-compatible data line.""" return json.dumps(payload, default=str, ensure_ascii=False) def _get_disk_stats() -> tuple[float, float]: """Return (used_gb, total_gb) for the data volume or cwd fallback.""" # Prefer /data mount when present (production), else use cwd target = "/data" if os.path.isdir("/data") else "." try: usage = shutil.disk_usage(target) used_gb = round(usage.used / (1024 ** 3), 2) total_gb = round(usage.total / (1024 ** 3), 2) return used_gb, total_gb except OSError as e: logger.warning(f"disk_usage failed for {target}: {e}") return 0.0, 0.0 def _count_active_sessions() -> int: """Count currently-revoked-free active sessions. Best-effort estimate: we don't keep an in-memory session store, so we fall back to 1 if the server is up. Useful for the dashboard tile. """ return 1 # ── /api/admin/stats ──────────────────────────────────────────────────── @router.get("/stats") async def get_admin_stats(_admin=Depends(require_admin)): """Return real-time system stats for the admin dashboard.""" import psutil try: cpu_pct = psutil.cpu_percent(interval=None) except Exception: cpu_pct = 0.0 try: vm = psutil.virtual_memory() mem_used_mb = round(vm.used / (1024 ** 2), 1) mem_total_mb = round(vm.total / (1024 ** 2), 1) except Exception: mem_used_mb, mem_total_mb = 0.0, 0.0 disk_used_gb, disk_total_gb = _get_disk_stats() uptime_seconds = int(_server_uptime_seconds()) active_sessions = _count_active_sessions() return { "cpu_pct": cpu_pct, "mem_used_mb": mem_used_mb, "mem_total_mb": mem_total_mb, "disk_used_gb": disk_used_gb, "disk_total_gb": disk_total_gb, "uptime_seconds": uptime_seconds, "active_sessions": active_sessions, } # ── /api/admin/audit ─────────────────────────────────────────────────── @router.get("/audit") async def get_admin_audit( user: str | None = Query(None, description="Filter by username (substring)"), action: str | None = Query(None, description="Filter by exact action"), limit: int = Query(500, ge=1, le=2000, description="Max entries"), offset: int = Query(0, ge=0, description="Skip first N entries"), _admin=Depends(require_admin), ): """Return recent audit log entries with optional filters.""" entries = get_recent_entries(limit=offset + limit, action=action) if user: needle = user.lower() entries = [e for e in entries if needle in str(e.get("username", "")).lower()] if offset: entries = entries[offset:] return { "entries": entries, "total": len(entries), "log_file": str(AUDIT_LOG_FILE), } # ── /api/admin/backup-stats ─────────────────────────────────────────── def _scan_backups() -> list[dict]: """Walk every vault's backup directory and return one row per .bak file.""" rows: list[dict] = [] for vault_name in list(index.keys()): vd = get_vault_data(vault_name) if not vd: continue vault_root = Path(vd["path"]) backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup")) if not backup_root.is_absolute(): backup_root = vault_root / backup_root vault_backup_dir = backup_root / vault_name if not vault_backup_dir.exists(): continue try: for fpath in vault_backup_dir.rglob("*.bak"): if not fpath.is_file(): continue try: st = fpath.stat() except OSError: continue # Backup filename: {orig}.{timestamp}.bak — split from the right name = fpath.name parts = name.rsplit(".", 2) if len(parts) < 3 or not parts[-2].isdigit(): continue try: ts = int(parts[-2]) except ValueError: continue rows.append({ "vault": vault_name, "filename": name, "timestamp": ts, "size": st.st_size, }) except OSError as e: logger.warning(f"Backup scan error in {vault_backup_dir}: {e}") rows.sort(key=lambda r: r["timestamp"], reverse=True) return rows @router.get("/backup-stats") async def get_admin_backup_stats(_admin=Depends(require_admin)): """Return backup statistics across all vaults.""" rows = _scan_backups() now_ts = int(time.time()) total_size_mb = round(sum(r["size"] for r in rows) / (1024 ** 2), 2) by_vault: dict[str, dict[str, Any]] = {} for r in rows: bucket = by_vault.setdefault(r["vault"], {"count": 0, "size_mb": 0.0}) bucket["count"] += 1 bucket["size_mb"] = round(bucket["size_mb"] + r["size"] / (1024 ** 2), 2) if rows: newest_ts = rows[0]["timestamp"] oldest_ts = rows[-1]["timestamp"] newest_age_days = round((now_ts - newest_ts) / 86400, 2) oldest_age_days = round((now_ts - oldest_ts) / 86400, 2) else: newest_age_days = 0.0 oldest_age_days = 0.0 return { "total_backups": len(rows), "total_size_mb": total_size_mb, "oldest_age_days": oldest_age_days, "newest_age_days": newest_age_days, "by_vault": by_vault, } # ── /api/admin/stream — Server-Sent Events ───────────────────────────── async def _stats_event_generator(): """Yield an SSE `stats` event with the current system stats.""" try: import psutil # Prime cpu_percent so the first real measurement isn't 0.0 psutil.cpu_percent(interval=None) except Exception: pass last_keepalive = time.time() KEEPALIVE_INTERVAL = 15.0 STATS_INTERVAL = 5.0 try: while True: now = time.time() try: data = await asyncio.to_thread(_build_stats_payload) except Exception as e: logger.warning(f"SSE stats build failed: {e}") data = {"error": str(e)} yield f"event: stats\ndata: {_format_timestamp_for_stream(data)}\n\n" if now - last_keepalive >= KEEPALIVE_INTERVAL: last_keepalive = now yield ": keepalive\n\n" await asyncio.sleep(STATS_INTERVAL) except asyncio.CancelledError: logger.info("SSE stats stream cancelled") raise def _build_stats_payload() -> dict: """Synchronous stats builder for the SSE generator.""" import psutil cpu_pct = psutil.cpu_percent(interval=None) vm = psutil.virtual_memory() disk_used_gb, disk_total_gb = _get_disk_stats() return { "cpu_pct": cpu_pct, "mem_used_mb": round(vm.used / (1024 ** 2), 1), "mem_total_mb": round(vm.total / (1024 ** 2), 1), "disk_used_gb": disk_used_gb, "disk_total_gb": disk_total_gb, "uptime_seconds": int(_server_uptime_seconds()), "timestamp": datetime.now(timezone.utc).isoformat(), } @router.get("/stream") async def stream_admin_stats(_admin=Depends(require_admin)): """Server-Sent Events stream of system metrics, every 5 seconds.""" return StreamingResponse( _stats_event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive", }, )