system.py 20 KB

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