system.py 22 KB

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