system.py 17 KB

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