"""Log viewing and snapshot endpoints.""" from __future__ import annotations import asyncio import json import platform import shutil import socket import sys from collections import deque from datetime import datetime from fastapi import APIRouter, HTTPException from fastapi.responses import PlainTextResponse from Project.Sanad.config import BASE_DIR, LOGS_DIR from Project.Sanad.dashboard.routes._safe_io import safe_path_under router = APIRouter() def _list_logs_sync(): LOGS_DIR.mkdir(parents=True, exist_ok=True) files = [] for p in sorted(LOGS_DIR.glob("*.log*")): files.append({ "name": p.name, "size_bytes": p.stat().st_size, }) return files @router.get("/") async def list_logs(): files = await asyncio.to_thread(_list_logs_sync) return {"logs_dir": str(LOGS_DIR), "files": files} def _tail_sync(path, lines: int) -> list[str]: with open(path, "r", encoding="utf-8", errors="replace") as f: tail = deque(f, maxlen=lines) return [l.rstrip("\n") for l in tail] @router.get("/tail/{filename}") async def tail_log(filename: str, lines: int = 200): path = safe_path_under(LOGS_DIR, filename) if not path.exists(): raise HTTPException(404, "File not found") lines_out = await asyncio.to_thread(_tail_sync, path, lines) return {"filename": path.name, "lines": lines_out} def _snapshot_sync(ts: str): saved = [] for p in LOGS_DIR.glob("*.log"): # Skip prior snapshots to avoid recursive growth if "_snapshot_" in p.stem: continue dest = LOGS_DIR / f"{p.stem}_snapshot_{ts}.log" shutil.copy2(p, dest) saved.append({"source": p.name, "snapshot": dest.name, "size_bytes": dest.stat().st_size}) return saved @router.post("/snapshot") async def save_log_snapshot(): """Save timestamped copy of all log files.""" LOGS_DIR.mkdir(parents=True, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") saved = await asyncio.to_thread(_snapshot_sync, ts) return {"ok": True, "saved_at": ts, "snapshots": saved} # ─────────────────────── full bundle (everything in one text blob) ─────────────────────── def _build_bundle_sync(lines_per_file: int, include_system: bool) -> str: """Build the full text bundle — header, subsystem status, all logs. Returns a single string safe to copy directly into a bug report. """ out: list[str] = [] ts = datetime.now().isoformat(timespec="seconds") out.append("=" * 72) out.append(f"SANAD LOG BUNDLE — {ts}") out.append("=" * 72) out.append(f"Hostname : {socket.gethostname()}") out.append(f"Platform : {platform.platform()}") out.append(f"Python : {sys.version.split()[0]}") out.append(f"Executable: {sys.executable}") out.append(f"BASE_DIR : {BASE_DIR}") out.append(f"LOGS_DIR : {LOGS_DIR}") # Subsystems — pull live status from main.SUBSYSTEMS if include_system: out.append("") out.append("-" * 72) out.append("SUBSYSTEMS") out.append("-" * 72) try: from Project.Sanad.main import SUBSYSTEMS except Exception as exc: out.append(f" could not import SUBSYSTEMS: {exc}") SUBSYSTEMS = {} for name in sorted(SUBSYSTEMS): comp = SUBSYSTEMS[name] if comp is None: out.append(f" ✗ {name:15s} unavailable") continue status: dict = {} if hasattr(comp, "status") and callable(comp.status): try: s = comp.status() if isinstance(s, dict): status = s else: status = {"raw": str(s)} except Exception as exc: status = {"status_error": str(exc)} try: status_str = json.dumps(status, ensure_ascii=False, default=str) except Exception: status_str = str(status) out.append(f" ✓ {name:15s} {status_str}") # Dashboard router load state out.append("") out.append("-" * 72) out.append("DASHBOARD ROUTERS") out.append("-" * 72) try: from Project.Sanad.dashboard.app import _loaded_routes, _failed_routes out.append(f" loaded ({len(_loaded_routes)}): {', '.join(_loaded_routes)}") if _failed_routes: out.append(f" failed ({len(_failed_routes)}):") for name, err in _failed_routes.items(): out.append(f" - {name}: {err}") else: out.append(" failed (0): —") except Exception as exc: out.append(f" could not read dashboard state: {exc}") # All log files — tail N lines each, skip snapshots out.append("") out.append("-" * 72) out.append(f"LOG FILES (last {lines_per_file} lines each)") out.append("-" * 72) LOGS_DIR.mkdir(parents=True, exist_ok=True) log_paths = sorted(LOGS_DIR.glob("*.log*")) files_included = 0 for p in log_paths: if "_snapshot_" in p.stem: continue # skip stale snapshots try: size = p.stat().st_size except OSError: size = 0 out.append("") out.append(f"=== {p.name} ({size} bytes) ===") try: with open(p, "r", encoding="utf-8", errors="replace") as f: tail = deque(f, maxlen=lines_per_file) for raw in tail: out.append(raw.rstrip("\n")) files_included += 1 except OSError as exc: out.append(f" ") out.append("") out.append("=" * 72) out.append(f"END OF BUNDLE — {files_included} log file(s) included") out.append("=" * 72) return "\n".join(out) @router.get("/bundle") async def logs_bundle(lines: int = 1000, include_system: bool = True): """Return a single plain-text dump of everything useful for debugging. Includes: - Timestamp, hostname, platform, Python, BASE_DIR, LOGS_DIR - Live status of every subsystem in main.SUBSYSTEMS - Dashboard router load/fail state - Tail of every .log file in LOGS_DIR (configurable per-file limit) Response is `text/plain` so it's safe to copy straight to clipboard or pipe into a file. Intended use: dashboard "Copy All Logs" button and manual `curl ... > sanad_bundle.txt` debugging. """ # Clamp lines to keep the payload sane lines = max(10, min(int(lines), 50000)) text = await asyncio.to_thread(_build_bundle_sync, lines, include_system) return PlainTextResponse( text, headers={ "Content-Disposition": ( f'inline; filename="sanad_bundle_{datetime.now().strftime("%Y%m%d_%H%M%S")}.txt"' ), }, )