system.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. """System information API routes."""
  2. import asyncio
  3. import os
  4. import platform
  5. import time
  6. from collections.abc import Callable
  7. from datetime import datetime
  8. from pathlib import Path
  9. import psutil
  10. from fastapi import APIRouter, Depends
  11. from sqlalchemy import func, select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  14. from backend.app.core.config import APP_VERSION, settings
  15. from backend.app.core.database import get_db
  16. from backend.app.core.permissions import Permission
  17. from backend.app.models.archive import PrintArchive
  18. from backend.app.models.filament import Filament
  19. from backend.app.models.printer import Printer
  20. from backend.app.models.project import Project
  21. from backend.app.models.smart_plug import SmartPlug
  22. from backend.app.models.user import User
  23. from backend.app.services.log_health import ScanResult, scan_logs
  24. from backend.app.services.log_reader import collect_sensitive_strings
  25. from backend.app.services.printer_manager import printer_manager
  26. router = APIRouter(prefix="/system", tags=["system"])
  27. STORAGE_USAGE_CACHE_SECONDS = 300
  28. _storage_usage_cache: dict | None = None
  29. _storage_usage_cache_ts: float | None = None
  30. _storage_usage_lock = asyncio.Lock()
  31. def get_directory_size(path: Path) -> int:
  32. """Calculate total size of a directory in bytes."""
  33. total = 0
  34. try:
  35. for entry in path.rglob("*"):
  36. if entry.is_file():
  37. total += entry.stat().st_size
  38. except (PermissionError, OSError):
  39. pass # Return partial total if directory traversal is interrupted
  40. return total
  41. def format_bytes(bytes_value: int) -> str:
  42. """Format bytes to human-readable string."""
  43. for unit in ["B", "KB", "MB", "GB", "TB"]:
  44. if bytes_value < 1024:
  45. return f"{bytes_value:.1f} {unit}"
  46. bytes_value /= 1024
  47. return f"{bytes_value:.1f} PB"
  48. def format_uptime(seconds: float) -> str:
  49. """Format uptime in seconds to human-readable string."""
  50. days = int(seconds // 86400)
  51. hours = int((seconds % 86400) // 3600)
  52. minutes = int((seconds % 3600) // 60)
  53. parts = []
  54. if days > 0:
  55. parts.append(f"{days}d")
  56. if hours > 0:
  57. parts.append(f"{hours}h")
  58. if minutes > 0:
  59. parts.append(f"{minutes}m")
  60. return " ".join(parts) if parts else "< 1m"
  61. def _is_under(path: Path, root: Path) -> bool:
  62. try:
  63. path.resolve().relative_to(root.resolve())
  64. return True
  65. except ValueError:
  66. return False
  67. def _get_database_paths() -> list[Path]:
  68. from backend.app.core.db_dialect import is_sqlite
  69. if not is_sqlite():
  70. return [] # PostgreSQL — no local DB files
  71. candidates = [settings.base_dir / "bambuddy.db", settings.base_dir / "bambutrack.db"]
  72. return [path for path in candidates if path.exists()]
  73. def _get_database_items() -> list[dict]:
  74. items: list[dict] = []
  75. for path in _get_database_paths():
  76. try:
  77. size = path.stat().st_size
  78. except OSError:
  79. continue
  80. items.append(
  81. {
  82. "name": path.name,
  83. "path": str(path),
  84. "bytes": size,
  85. "formatted": format_bytes(size),
  86. }
  87. )
  88. items.sort(key=lambda item: item["bytes"], reverse=True)
  89. return items
  90. def _get_app_dir() -> Path:
  91. return settings.static_dir.parent
  92. def _get_data_dirs() -> list[Path]:
  93. return [
  94. settings.archive_dir,
  95. settings.log_dir,
  96. settings.plate_calibration_dir,
  97. settings.base_dir / "virtual_printer",
  98. settings.base_dir / "firmware",
  99. ]
  100. def _is_system_path(path: Path) -> bool:
  101. app_dir = _get_app_dir()
  102. if not _is_under(path, app_dir):
  103. return False
  104. return all(not _is_under(path, data_dir) for data_dir in _get_data_dirs())
  105. def _get_storage_rules() -> list[tuple[str, str, Callable]]:
  106. base_dir = settings.base_dir
  107. archive_dir = settings.archive_dir
  108. library_dir = archive_dir / "library"
  109. virtual_printer_dir = base_dir / "virtual_printer"
  110. upload_dir = virtual_printer_dir / "uploads"
  111. db_paths = set(_get_database_paths())
  112. return [
  113. (
  114. "database",
  115. "Database",
  116. lambda path: path in db_paths,
  117. ),
  118. (
  119. "library_thumbnails",
  120. "Library Thumbnails",
  121. lambda path: _is_under(path, library_dir / "thumbnails"),
  122. ),
  123. (
  124. "library_files",
  125. "Library Files",
  126. lambda path: _is_under(path, library_dir / "files"),
  127. ),
  128. (
  129. "library_other",
  130. "Library Other",
  131. lambda path: _is_under(path, library_dir),
  132. ),
  133. (
  134. "archive_timelapses",
  135. "Timelapses",
  136. lambda path: _is_under(path, archive_dir) and "timelapse" in path.name.lower(),
  137. ),
  138. (
  139. "archive_thumbnails",
  140. "Thumbnails",
  141. lambda path: _is_under(path, archive_dir) and path.name.lower().startswith("thumbnail"),
  142. ),
  143. (
  144. "archive_files",
  145. "Archives",
  146. lambda path: _is_under(path, archive_dir),
  147. ),
  148. (
  149. "virtual_printer_upload_cache",
  150. "Virtual Printer Upload Cache",
  151. lambda path: _is_under(path, upload_dir / "cache"),
  152. ),
  153. (
  154. "virtual_printer_uploads",
  155. "Virtual Printer Uploads",
  156. lambda path: _is_under(path, upload_dir),
  157. ),
  158. (
  159. "virtual_printer_certs",
  160. "Virtual Printer Certs",
  161. lambda path: _is_under(path, virtual_printer_dir / "certs"),
  162. ),
  163. (
  164. "virtual_printer_other",
  165. "Virtual Printer Other",
  166. lambda path: _is_under(path, virtual_printer_dir),
  167. ),
  168. (
  169. "downloads",
  170. "Downloads",
  171. lambda path: _is_under(path, base_dir / "firmware"),
  172. ),
  173. (
  174. "plate_calibration",
  175. "Plate Calibration",
  176. lambda path: _is_under(path, settings.plate_calibration_dir),
  177. ),
  178. (
  179. "logs",
  180. "Logs",
  181. lambda path: _is_under(path, settings.log_dir),
  182. ),
  183. ]
  184. def _classify_file(path: Path, rules: list[tuple[str, str, Callable]]) -> tuple[str, str]:
  185. for key, label, matcher in rules:
  186. try:
  187. if matcher(path):
  188. return key, label
  189. except OSError:
  190. continue
  191. return "other_data", "Other"
  192. def _format_percentage(part: int, total: int) -> float:
  193. if total <= 0:
  194. return 0.0
  195. return round((part / total) * 100, 2)
  196. def _get_other_bucket(path: Path, base_dir: Path) -> str:
  197. try:
  198. relative = path.resolve().relative_to(base_dir.resolve())
  199. except ValueError:
  200. return path.parent.name or path.name
  201. parts = relative.parts
  202. return parts[0] if parts else path.name
  203. def _walk_files(roots: list[Path]) -> list[Path]:
  204. files: list[Path] = []
  205. stack = [root for root in roots if root.exists()]
  206. while stack:
  207. current = stack.pop()
  208. try:
  209. with os.scandir(current) as entries:
  210. for entry in entries:
  211. try:
  212. if entry.is_symlink():
  213. continue
  214. if entry.is_dir(follow_symlinks=False):
  215. stack.append(Path(entry.path))
  216. elif entry.is_file(follow_symlinks=False):
  217. files.append(Path(entry.path))
  218. except OSError:
  219. continue
  220. except OSError:
  221. continue
  222. return files
  223. def _scan_storage_usage() -> dict:
  224. base_dir = settings.base_dir
  225. rules = _get_storage_rules()
  226. roots = _get_data_dirs()
  227. seen_roots = set()
  228. unique_roots = []
  229. for root in roots:
  230. resolved = root.resolve()
  231. if resolved not in seen_roots:
  232. seen_roots.add(resolved)
  233. unique_roots.append(root)
  234. total_bytes = 0
  235. error_count = 0
  236. category_sizes: dict[str, dict] = {}
  237. other_breakdown: dict[tuple[str, str], int] = {}
  238. database_items = _get_database_items()
  239. files = _walk_files(unique_roots)
  240. for file_path in files:
  241. try:
  242. size = file_path.stat().st_size
  243. except OSError:
  244. error_count += 1
  245. continue
  246. total_bytes += size
  247. key, label = _classify_file(file_path, rules)
  248. if key not in category_sizes:
  249. category_sizes[key] = {"key": key, "label": label, "bytes": 0}
  250. category_sizes[key]["bytes"] += size
  251. if key == "other_data":
  252. bucket = _get_other_bucket(file_path, base_dir)
  253. kind = "system" if _is_system_path(file_path) else "data"
  254. other_breakdown[(bucket, kind)] = other_breakdown.get((bucket, kind), 0) + size
  255. for item in database_items:
  256. total_bytes += item["bytes"]
  257. key = "database"
  258. label = "Database"
  259. if key not in category_sizes:
  260. category_sizes[key] = {"key": key, "label": label, "bytes": 0}
  261. category_sizes[key]["bytes"] += item["bytes"]
  262. categories = []
  263. for item in category_sizes.values():
  264. bytes_value = item["bytes"]
  265. categories.append(
  266. {
  267. "key": item["key"],
  268. "label": item["label"],
  269. "bytes": bytes_value,
  270. "formatted": format_bytes(bytes_value),
  271. "percent_of_total": _format_percentage(bytes_value, total_bytes),
  272. }
  273. )
  274. categories.sort(key=lambda entry: entry["bytes"], reverse=True)
  275. other_items = []
  276. for (bucket, kind), size in other_breakdown.items():
  277. other_items.append(
  278. {
  279. "bucket": bucket,
  280. "label": bucket,
  281. "kind": kind,
  282. "deletable": kind != "system",
  283. "bytes": size,
  284. "formatted": format_bytes(size),
  285. "percent_of_total": _format_percentage(size, total_bytes),
  286. }
  287. )
  288. other_items.sort(key=lambda entry: entry["bytes"], reverse=True)
  289. return {
  290. "roots": [str(root) for root in unique_roots],
  291. "total_bytes": total_bytes,
  292. "total_formatted": format_bytes(total_bytes),
  293. "categories": categories,
  294. "other_breakdown": other_items,
  295. "scan_errors": error_count,
  296. }
  297. async def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict:
  298. global _storage_usage_cache
  299. global _storage_usage_cache_ts
  300. now = time.time()
  301. if not refresh and _storage_usage_cache and _storage_usage_cache_ts is not None:
  302. age = now - _storage_usage_cache_ts
  303. if age < max_age_seconds:
  304. return {
  305. **_storage_usage_cache,
  306. "cache": {
  307. "hit": True,
  308. "age_seconds": round(age, 2),
  309. "max_age_seconds": max_age_seconds,
  310. },
  311. }
  312. async with _storage_usage_lock:
  313. now = time.time()
  314. if not refresh and _storage_usage_cache and _storage_usage_cache_ts is not None:
  315. age = now - _storage_usage_cache_ts
  316. if age < max_age_seconds:
  317. return {
  318. **_storage_usage_cache,
  319. "cache": {
  320. "hit": True,
  321. "age_seconds": round(age, 2),
  322. "max_age_seconds": max_age_seconds,
  323. },
  324. }
  325. snapshot = await asyncio.to_thread(_scan_storage_usage)
  326. _storage_usage_cache = {
  327. **snapshot,
  328. "generated_at": datetime.now().isoformat(),
  329. }
  330. _storage_usage_cache_ts = time.time()
  331. return {
  332. **_storage_usage_cache,
  333. "cache": {
  334. "hit": False,
  335. "age_seconds": 0,
  336. "max_age_seconds": max_age_seconds,
  337. },
  338. }
  339. @router.get("/info")
  340. async def get_system_info(
  341. db: AsyncSession = Depends(get_db),
  342. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  343. ):
  344. """Get comprehensive system information."""
  345. # Database stats
  346. archive_count = await db.scalar(select(func.count(PrintArchive.id)))
  347. printer_count = await db.scalar(select(func.count(Printer.id)))
  348. filament_count = await db.scalar(select(func.count(Filament.id)))
  349. project_count = await db.scalar(select(func.count(Project.id)))
  350. smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))
  351. # Archive stats by status
  352. completed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
  353. failed_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
  354. printing_count = await db.scalar(select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing"))
  355. # Total print time
  356. total_print_time = (
  357. await db.scalar(
  358. select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.print_time_seconds.isnot(None))
  359. )
  360. or 0
  361. )
  362. # Total filament used
  363. total_filament = (
  364. await db.scalar(
  365. select(func.sum(PrintArchive.filament_used_grams)).where(PrintArchive.filament_used_grams.isnot(None))
  366. )
  367. or 0
  368. )
  369. # Connected printers
  370. connected_printers = []
  371. for printer_id, client in printer_manager._clients.items():
  372. state = client.state
  373. if state and state.connected:
  374. # Get printer name and model from database
  375. result = await db.execute(select(Printer.name, Printer.model).where(Printer.id == printer_id))
  376. row = result.first()
  377. name = row[0] if row else f"Printer {printer_id}"
  378. model = row[1] if row else "unknown"
  379. connected_printers.append(
  380. {
  381. "id": printer_id,
  382. "name": name,
  383. "state": state.state,
  384. "model": model,
  385. }
  386. )
  387. # Storage info
  388. archive_dir = settings.archive_dir
  389. archive_size = get_directory_size(archive_dir) if archive_dir.exists() else 0
  390. # Database info (engine type, version, size)
  391. from backend.app.core.db_dialect import is_postgres, is_sqlite
  392. db_engine_info: dict = {"engine": "unknown", "version": "unknown"}
  393. db_size = 0
  394. try:
  395. if is_postgres():
  396. from sqlalchemy import text
  397. result = await db.execute(text("SELECT version()"))
  398. pg_version_full = result.scalar() or "unknown"
  399. # e.g. "PostgreSQL 16.2 on x86_64..." → "PostgreSQL 16.2"
  400. pg_version = " ".join(pg_version_full.split()[:2])
  401. result = await db.execute(text("SELECT pg_database_size(current_database())"))
  402. db_size = result.scalar() or 0
  403. db_engine_info = {
  404. "engine": "PostgreSQL",
  405. "version": pg_version,
  406. }
  407. elif is_sqlite():
  408. from sqlalchemy import text
  409. result = await db.execute(text("SELECT sqlite_version()"))
  410. sqlite_ver = result.scalar() or "unknown"
  411. db_path = settings.base_dir / "bambuddy.db"
  412. db_size = db_path.stat().st_size if db_path.exists() else 0
  413. db_engine_info = {
  414. "engine": "SQLite",
  415. "version": f"SQLite {sqlite_ver}",
  416. }
  417. except Exception:
  418. pass
  419. # Disk usage
  420. disk = psutil.disk_usage(str(settings.base_dir))
  421. # System info
  422. memory = psutil.virtual_memory()
  423. boot_time = datetime.fromtimestamp(psutil.boot_time())
  424. uptime_seconds = (datetime.now() - boot_time).total_seconds()
  425. # Python and system info
  426. import sys
  427. return {
  428. "app": {
  429. "version": APP_VERSION,
  430. "base_dir": str(settings.base_dir),
  431. "archive_dir": str(archive_dir),
  432. },
  433. "database": {
  434. "engine": db_engine_info["engine"],
  435. "version": db_engine_info["version"],
  436. "archives": archive_count,
  437. "archives_completed": completed_count,
  438. "archives_failed": failed_count,
  439. "archives_printing": printing_count,
  440. "printers": printer_count,
  441. "filaments": filament_count,
  442. "projects": project_count,
  443. "smart_plugs": smart_plug_count,
  444. "total_print_time_seconds": total_print_time,
  445. "total_print_time_formatted": format_uptime(total_print_time),
  446. "total_filament_grams": round(total_filament, 1),
  447. "total_filament_kg": round(total_filament / 1000, 2),
  448. },
  449. "printers": {
  450. "total": printer_count,
  451. "connected": len(connected_printers),
  452. "connected_list": connected_printers,
  453. },
  454. "storage": {
  455. "archive_size_bytes": archive_size,
  456. "archive_size_formatted": format_bytes(archive_size),
  457. "database_size_bytes": db_size,
  458. "database_size_formatted": format_bytes(db_size),
  459. "disk_total_bytes": disk.total,
  460. "disk_total_formatted": format_bytes(disk.total),
  461. "disk_used_bytes": disk.used,
  462. "disk_used_formatted": format_bytes(disk.used),
  463. "disk_free_bytes": disk.free,
  464. "disk_free_formatted": format_bytes(disk.free),
  465. "disk_percent_used": disk.percent,
  466. },
  467. "system": {
  468. "platform": platform.system(),
  469. "platform_release": platform.release(),
  470. "platform_version": platform.version(),
  471. "architecture": platform.machine(),
  472. "hostname": platform.node(),
  473. "python_version": sys.version.split()[0],
  474. "uptime_seconds": uptime_seconds,
  475. "uptime_formatted": format_uptime(uptime_seconds),
  476. "boot_time": boot_time.isoformat(),
  477. },
  478. "memory": {
  479. "total_bytes": memory.total,
  480. "total_formatted": format_bytes(memory.total),
  481. "available_bytes": memory.available,
  482. "available_formatted": format_bytes(memory.available),
  483. "used_bytes": memory.used,
  484. "used_formatted": format_bytes(memory.used),
  485. "percent_used": memory.percent,
  486. },
  487. "cpu": {
  488. "count": psutil.cpu_count(),
  489. "count_logical": psutil.cpu_count(logical=True),
  490. "percent": psutil.cpu_percent(interval=0.1),
  491. },
  492. }
  493. @router.get("/storage-usage")
  494. async def get_storage_usage(
  495. refresh: bool = False,
  496. max_age_seconds: int = STORAGE_USAGE_CACHE_SECONDS,
  497. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  498. ):
  499. """Get storage usage breakdown for Bambuddy data directories."""
  500. max_age_seconds = max(0, min(max_age_seconds, 3600))
  501. return await _get_storage_usage_cached(refresh=refresh, max_age_seconds=max_age_seconds)
  502. @router.get("/health", response_model=ScanResult)
  503. async def get_system_health(
  504. db: AsyncSession = Depends(get_db),
  505. _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
  506. ):
  507. """Scan the recent application log against the known-issue catalog.
  508. Powers the self-service triage surfaces (System page + bug reporter).
  509. Sample lines are sanitized before they leave the process.
  510. """
  511. sensitive_strings = await collect_sensitive_strings(db)
  512. return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)