204 lines
6.9 KiB
Python
204 lines
6.9 KiB
Python
"""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" <could not read: {exc}>")
|
|
|
|
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"'
|
|
),
|
|
},
|
|
)
|