316 lines
11 KiB
Python
316 lines
11 KiB
Python
"""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)
|