"""System information endpoints — network, subsystems, dashboard binding.""" from __future__ import annotations import asyncio import os import platform import shutil import socket import sys from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException from pydantic import BaseModel from Project.Sanad.config import ( AUDIO_RECORDINGS_DIR, BASE_DIR, DASHBOARD_HOST, DASHBOARD_INTERFACE, DASHBOARD_PORT, DATA_DIR, DDS_NETWORK_INTERFACE, LOGS_DIR, list_network_interfaces, ) from Project.Sanad.core.logger import get_logger log = get_logger("system_route") router = APIRouter() def _runtime_bind() -> tuple[str, int]: """The host/port the server is ACTUALLY bound to. main.py launches `uvicorn.run(_app, host=args.host, port=args.port)` with the CLI --host/--port (start_sanad.sh passes `--port $PORT`, default 8001), which can differ from the import-time DASHBOARD_HOST/DASHBOARD_PORT config defaults (port 8000). Reading the live argv reports the real URL instead of a stale config value. Falls back to the config constants when an arg is absent (e.g. argparse default in effect).""" host = DASHBOARD_HOST port = DASHBOARD_PORT argv = sys.argv for i, tok in enumerate(argv): if tok == "--host" and i + 1 < len(argv): host = argv[i + 1] elif tok.startswith("--host="): host = tok.split("=", 1)[1] elif tok == "--port" and i + 1 < len(argv): try: port = int(argv[i + 1]) except (TypeError, ValueError): pass elif tok.startswith("--port="): try: port = int(tok.split("=", 1)[1]) except (TypeError, ValueError): pass return host, port def _safe_status(component, name: str) -> dict[str, Any]: if component is None: return {"available": False} try: if hasattr(component, "status") and callable(component.status): s = component.status() if not isinstance(s, dict): s = {"raw": str(s)} s.setdefault("available", True) return s return {"available": True} except Exception as exc: log.warning("status() failed for %s: %s", name, exc) return {"available": True, "error": str(exc)} @router.get("/info") async def system_info(): """One-shot system snapshot for the dashboard system panel.""" def _do(): # Subsystems try: from Project.Sanad.main import SUBSYSTEMS except Exception: SUBSYSTEMS = {} subsystem_list = [] for name in sorted(SUBSYSTEMS): comp = SUBSYSTEMS[name] entry = { "name": name, "connected": comp is not None, } if comp is not None and hasattr(comp, "status") and callable(comp.status): try: s = comp.status() if isinstance(s, dict): entry["status"] = s except Exception as exc: entry["status_error"] = str(exc) subsystem_list.append(entry) connected_count = sum(1 for s in subsystem_list if s["connected"]) # Audio device current selection (best-effort) audio_info = {} try: from Project.Sanad.voice import audio_devices as ad audio_info = { "pactl_available": ad.pactl_available(), "current": ad.current_selection(), "detected_profile_ids": [ d["profile"]["id"] for d in ad.detect_plugged_profiles() ] if ad.pactl_available() else [], } except Exception as exc: audio_info = {"error": str(exc)} # Network interfaces try: interfaces = list_network_interfaces() except Exception: interfaces = [] # Determine the URL the dashboard is reachable at — use the ACTUAL # runtime bind args (argv), not the import-time config defaults. bound_host, bound_port = _runtime_bind() if bound_host == "0.0.0.0": # Try to find the wlan0 IP for display purposes up_ifaces = [i for i in interfaces if i["is_up"] and i["ip"] and not i["ip"].startswith("127.")] display_host = up_ifaces[0]["ip"] if up_ifaces else bound_host else: display_host = bound_host return { "host": { "hostname": socket.gethostname(), "platform": platform.platform(), "python": sys.version.split()[0], "executable": sys.executable, "base_dir": str(BASE_DIR), "pid": os.getpid(), }, "dashboard": { "interface": DASHBOARD_INTERFACE, "bound_host": bound_host, "display_host": display_host, "port": bound_port, "url": f"http://{display_host}:{bound_port}", }, "dds": { "interface": DDS_NETWORK_INTERFACE, }, "network": { "interfaces": interfaces, }, "subsystems": { "total": len(subsystem_list), "connected": connected_count, "disconnected": len(subsystem_list) - connected_count, "list": subsystem_list, }, "audio": audio_info, } return await asyncio.to_thread(_do) # ───────────────────── storage tracking + cleanup ───────────────────── # Categories surfaced in the Settings → Storage panel. `cleanable` ones get a # Clean button + are included in "Clean all"; the rest (faces/motions/zones) # are shown for tracking only — they're operational assets (enrollments, # motion configs) managed in their own tabs, not disposable clutter. _STORAGE_CATS = [ ("recordings", "Conversation recordings", DATA_DIR / "recordings", True), ("records", "Named records (Typed Replay)", AUDIO_RECORDINGS_DIR, True), ("logs", "Logs", LOGS_DIR, True), ("faces", "Enrolled faces", DATA_DIR / "faces", False), ("motions", "Motion replays + config", DATA_DIR / "motions", False), ("photos", "Photos", DATA_DIR / "photos", False), ("zones", "Vision zones", DATA_DIR / "zones", False), ] _CLEANABLE = {k for k, _l, _p, c in _STORAGE_CATS if c} def _dir_stats(path: Path) -> tuple[int, int]: """(total_bytes, file_count) of a dir tree. Missing dir → (0, 0).""" total, n = 0, 0 try: for root, _dirs, files in os.walk(path): for f in files: try: total += os.path.getsize(os.path.join(root, f)) n += 1 except OSError: pass except Exception: pass return total, n def _human(b: float) -> str: f = float(b) for u in ("B", "KB", "MB", "GB", "TB"): if f < 1024 or u == "TB": return f"{f:.0f} {u}" if u == "B" else f"{f:.1f} {u}" f /= 1024 return f"{f:.1f} TB" @router.get("/storage") async def storage_usage(): """Per-category data/log sizes + disk free, for the Storage panel.""" def _do(): cats = [] for key, label, path, cleanable in _STORAGE_CATS: size, files = _dir_stats(Path(path)) cats.append({ "key": key, "label": label, "path": str(path), "size_bytes": size, "size_human": _human(size), "files": files, "cleanable": cleanable, }) data_b, _ = _dir_stats(DATA_DIR) logs_b, _ = _dir_stats(LOGS_DIR) try: du = shutil.disk_usage(str(BASE_DIR)) disk = { "free_human": _human(du.free), "total_human": _human(du.total), "used_pct": round(100.0 * (du.total - du.free) / du.total, 1), } except Exception: disk = {} return { "categories": cats, "data_bytes": data_b, "data_human": _human(data_b), "logs_human": _human(logs_b), "total_human": _human(data_b + logs_b), "disk": disk, } return await asyncio.to_thread(_do) class _CleanReq(BaseModel): target: str # recordings | records | logs | all def _clean_recordings() -> tuple[int, int]: d = DATA_DIR / "recordings" freed, n = 0, 0 for f in list(d.glob("*.wav")) + [d / "index.json"]: if f.is_file(): try: freed += f.stat().st_size f.unlink() n += 1 except OSError: pass return n, freed def _clean_records() -> tuple[int, int]: d = AUDIO_RECORDINGS_DIR freed, n = 0, 0 for f in list(d.glob("*.wav")) + [d / "records.json"]: if f.is_file(): try: freed += f.stat().st_size f.unlink() n += 1 except OSError: pass return n, freed def _clean_logs() -> tuple[int, int]: # Truncate (not delete) — active loggers hold append-mode handles, so # truncating to 0 clears content cleanly without losing the fd. freed, n = 0, 0 for f in Path(LOGS_DIR).glob("*.log"): try: freed += f.stat().st_size open(f, "w").close() n += 1 except OSError: pass return n, freed @router.post("/storage/clean") async def storage_clean(req: _CleanReq): """Clean a disposable category (recordings | records | logs) or 'all'. Recordings/records are deleted; logs are truncated. Assets (faces, motions, zones) are never touched here.""" t = (req.target or "").strip().lower() if t != "all" and t not in _CLEANABLE: raise HTTPException(400, f"target must be 'all' or one of {sorted(_CLEANABLE)}") def _do(): targets = ["recordings", "records", "logs"] if t == "all" else [t] fns = {"recordings": _clean_recordings, "records": _clean_records, "logs": _clean_logs} result, total = {}, 0 for tg in targets: n, freed = fns[tg]() result[tg] = {"items": n, "freed_bytes": freed, "freed_human": _human(freed)} total += freed log.info("storage clean %s → freed %s", targets, _human(total)) return {"ok": True, "cleaned": targets, "total_freed_bytes": total, "total_freed_human": _human(total), "result": result} return await asyncio.to_thread(_do)