support.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. """Support endpoints for debug logging and support bundle generation."""
  2. import io
  3. import json
  4. import logging
  5. import os
  6. import platform
  7. import re
  8. import zipfile
  9. from datetime import datetime
  10. from fastapi import APIRouter, HTTPException, Query
  11. from fastapi.responses import StreamingResponse
  12. from pydantic import BaseModel
  13. from sqlalchemy import func, select
  14. from sqlalchemy.ext.asyncio import AsyncSession
  15. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  16. from backend.app.core.config import APP_VERSION, settings
  17. from backend.app.core.database import async_session
  18. from backend.app.core.permissions import Permission
  19. from backend.app.models.archive import PrintArchive
  20. from backend.app.models.filament import Filament
  21. from backend.app.models.printer import Printer
  22. from backend.app.models.project import Project
  23. from backend.app.models.settings import Settings
  24. from backend.app.models.smart_plug import SmartPlug
  25. from backend.app.models.user import User
  26. router = APIRouter(prefix="/support", tags=["support"])
  27. logger = logging.getLogger(__name__)
  28. # In-memory state for debug logging (persisted to settings DB)
  29. _debug_logging_enabled = False
  30. _debug_logging_enabled_at: datetime | None = None
  31. class DebugLoggingState(BaseModel):
  32. enabled: bool
  33. enabled_at: str | None = None
  34. duration_seconds: int | None = None
  35. class DebugLoggingToggle(BaseModel):
  36. enabled: bool
  37. async def _get_debug_setting(db: AsyncSession) -> tuple[bool, datetime | None]:
  38. """Get debug logging state from database."""
  39. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
  40. enabled_setting = result.scalar_one_or_none()
  41. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
  42. enabled_at_setting = result.scalar_one_or_none()
  43. enabled = enabled_setting.value.lower() == "true" if enabled_setting else False
  44. enabled_at = None
  45. if enabled_at_setting and enabled_at_setting.value:
  46. try:
  47. enabled_at = datetime.fromisoformat(enabled_at_setting.value)
  48. except ValueError:
  49. pass
  50. return enabled, enabled_at
  51. async def _set_debug_setting(db: AsyncSession, enabled: bool) -> datetime | None:
  52. """Set debug logging state in database."""
  53. # Update or create enabled setting
  54. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
  55. setting = result.scalar_one_or_none()
  56. if setting:
  57. setting.value = str(enabled).lower()
  58. else:
  59. db.add(Settings(key="debug_logging_enabled", value=str(enabled).lower()))
  60. # Update enabled_at timestamp
  61. enabled_at = datetime.now() if enabled else None
  62. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
  63. at_setting = result.scalar_one_or_none()
  64. if at_setting:
  65. at_setting.value = enabled_at.isoformat() if enabled_at else ""
  66. else:
  67. db.add(Settings(key="debug_logging_enabled_at", value=enabled_at.isoformat() if enabled_at else ""))
  68. await db.commit()
  69. return enabled_at
  70. def _apply_log_level(debug: bool):
  71. """Apply log level change to root logger."""
  72. root_logger = logging.getLogger()
  73. new_level = logging.DEBUG if debug else logging.INFO
  74. root_logger.setLevel(new_level)
  75. for handler in root_logger.handlers:
  76. handler.setLevel(new_level)
  77. # Also adjust third-party loggers
  78. if debug:
  79. logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
  80. logging.getLogger("httpcore").setLevel(logging.DEBUG)
  81. logging.getLogger("httpx").setLevel(logging.DEBUG)
  82. else:
  83. logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
  84. logging.getLogger("httpcore").setLevel(logging.WARNING)
  85. logging.getLogger("httpx").setLevel(logging.WARNING)
  86. logger.info("Log level changed to %s", "DEBUG" if debug else "INFO")
  87. @router.get("/debug-logging", response_model=DebugLoggingState)
  88. async def get_debug_logging_state(
  89. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  90. ):
  91. """Get current debug logging state."""
  92. global _debug_logging_enabled, _debug_logging_enabled_at
  93. async with async_session() as db:
  94. enabled, enabled_at = await _get_debug_setting(db)
  95. _debug_logging_enabled = enabled
  96. _debug_logging_enabled_at = enabled_at
  97. duration = None
  98. if enabled and enabled_at:
  99. duration = int((datetime.now() - enabled_at).total_seconds())
  100. return DebugLoggingState(
  101. enabled=enabled,
  102. enabled_at=enabled_at.isoformat() if enabled_at else None,
  103. duration_seconds=duration,
  104. )
  105. @router.post("/debug-logging", response_model=DebugLoggingState)
  106. async def toggle_debug_logging(
  107. toggle: DebugLoggingToggle,
  108. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  109. ):
  110. """Enable or disable debug logging."""
  111. global _debug_logging_enabled, _debug_logging_enabled_at
  112. async with async_session() as db:
  113. enabled_at = await _set_debug_setting(db, toggle.enabled)
  114. _debug_logging_enabled = toggle.enabled
  115. _debug_logging_enabled_at = enabled_at
  116. _apply_log_level(toggle.enabled)
  117. duration = None
  118. if toggle.enabled and enabled_at:
  119. duration = int((datetime.now() - enabled_at).total_seconds())
  120. return DebugLoggingState(
  121. enabled=toggle.enabled,
  122. enabled_at=enabled_at.isoformat() if enabled_at else None,
  123. duration_seconds=duration,
  124. )
  125. class LogEntry(BaseModel):
  126. """A single log entry."""
  127. timestamp: str
  128. level: str
  129. logger_name: str
  130. message: str
  131. class LogsResponse(BaseModel):
  132. """Response containing log entries."""
  133. entries: list[LogEntry]
  134. total_in_file: int
  135. filtered_count: int
  136. # Log line regex pattern: "2024-01-15 10:30:45,123 INFO [module.name] Message here"
  137. LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
  138. def _parse_log_line(line: str) -> LogEntry | None:
  139. """Parse a single log line into a LogEntry."""
  140. match = LOG_LINE_PATTERN.match(line.strip())
  141. if match:
  142. return LogEntry(
  143. timestamp=match.group(1),
  144. level=match.group(2),
  145. logger_name=match.group(3),
  146. message=match.group(4),
  147. )
  148. return None
  149. def _read_log_entries(
  150. limit: int = 200,
  151. level_filter: str | None = None,
  152. search: str | None = None,
  153. ) -> tuple[list[LogEntry], int]:
  154. """Read and parse log entries from file with optional filtering."""
  155. log_file = settings.log_dir / "bambuddy.log"
  156. if not log_file.exists():
  157. return [], 0
  158. entries: list[LogEntry] = []
  159. total_lines = 0
  160. try:
  161. with open(log_file, encoding="utf-8", errors="replace") as f:
  162. # Read all lines and process
  163. lines = f.readlines()
  164. total_lines = len(lines)
  165. # Parse lines in reverse order (newest first)
  166. current_entry: LogEntry | None = None
  167. multi_line_buffer: list[str] = []
  168. for line in reversed(lines):
  169. parsed = _parse_log_line(line)
  170. if parsed:
  171. # Found a new log entry start
  172. if current_entry:
  173. # Apply filters and add previous entry (without multi_line_buffer - it belongs to new entry)
  174. should_include = True
  175. # Level filter
  176. if level_filter and current_entry.level.upper() != level_filter.upper():
  177. should_include = False
  178. # Search filter (case-insensitive)
  179. if search and should_include:
  180. search_lower = search.lower()
  181. if not (
  182. search_lower in current_entry.message.lower()
  183. or search_lower in current_entry.logger_name.lower()
  184. ):
  185. should_include = False
  186. if should_include:
  187. entries.append(current_entry)
  188. if len(entries) >= limit:
  189. break
  190. # Set new entry and attach any accumulated multi-line content to it
  191. # (in reverse order, continuation lines come before their parent entry)
  192. current_entry = parsed
  193. if multi_line_buffer:
  194. current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
  195. multi_line_buffer = []
  196. elif line.strip():
  197. # Continuation of multi-line log entry (will be attached to next parsed entry)
  198. multi_line_buffer.append(line.rstrip())
  199. # Don't forget the last (oldest) entry
  200. # Note: any remaining multi_line_buffer would be orphaned lines before the first entry
  201. if current_entry and len(entries) < limit:
  202. should_include = True
  203. if level_filter and current_entry.level.upper() != level_filter.upper():
  204. should_include = False
  205. if search and should_include:
  206. search_lower = search.lower()
  207. if not (
  208. search_lower in current_entry.message.lower()
  209. or search_lower in current_entry.logger_name.lower()
  210. ):
  211. should_include = False
  212. if should_include:
  213. entries.append(current_entry)
  214. except Exception as e:
  215. logger.error("Error reading log file: %s", e)
  216. return [], 0
  217. # Entries are already in newest-first order
  218. return entries, total_lines
  219. @router.get("/logs", response_model=LogsResponse)
  220. async def get_logs(
  221. limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
  222. level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
  223. search: str | None = Query(None, description="Search in message or logger name"),
  224. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  225. ):
  226. """Get recent application log entries with optional filtering."""
  227. entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
  228. return LogsResponse(
  229. entries=entries,
  230. total_in_file=total_lines,
  231. filtered_count=len(entries),
  232. )
  233. @router.delete("/logs")
  234. async def clear_logs(
  235. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  236. ):
  237. """Clear the application log file."""
  238. log_file = settings.log_dir / "bambuddy.log"
  239. if log_file.exists():
  240. try:
  241. # Truncate the file instead of deleting (keeps file handles valid)
  242. with open(log_file, "w", encoding="utf-8") as f:
  243. f.write("")
  244. logger.info("Log file cleared by user")
  245. return {"message": "Logs cleared successfully"}
  246. except Exception as e:
  247. logger.error("Error clearing log file: %s", e, exc_info=True)
  248. raise HTTPException(status_code=500, detail="Failed to clear logs. Check server logs for details.")
  249. return {"message": "Log file does not exist"}
  250. def _sanitize_path(path: str) -> str:
  251. """Remove username from paths for privacy."""
  252. # Replace /home/username/ or /Users/username/ with /home/[user]/
  253. path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)
  254. path = re.sub(r"/Users/[^/]+/", "/Users/[user]/", path)
  255. # Replace /opt/username/ patterns
  256. path = re.sub(r"/opt/[^/]+/", "/opt/[user]/", path)
  257. return path
  258. async def _collect_support_info() -> dict:
  259. """Collect all support information."""
  260. info = {
  261. "generated_at": datetime.now().isoformat(),
  262. "app": {
  263. "version": APP_VERSION,
  264. "debug_mode": settings.debug,
  265. },
  266. "system": {
  267. "platform": platform.system(),
  268. "platform_release": platform.release(),
  269. "platform_version": platform.version(),
  270. "architecture": platform.machine(),
  271. "python_version": platform.python_version(),
  272. },
  273. "environment": {
  274. "docker": os.path.exists("/.dockerenv"),
  275. "data_dir": _sanitize_path(str(settings.base_dir)),
  276. "log_dir": _sanitize_path(str(settings.log_dir)),
  277. },
  278. "database": {},
  279. "printers": [],
  280. "settings": {},
  281. }
  282. async with async_session() as db:
  283. # Database stats
  284. result = await db.execute(select(func.count(PrintArchive.id)))
  285. info["database"]["archives_total"] = result.scalar() or 0
  286. result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
  287. info["database"]["archives_completed"] = result.scalar() or 0
  288. result = await db.execute(select(func.count(Printer.id)))
  289. info["database"]["printers_total"] = result.scalar() or 0
  290. result = await db.execute(select(func.count(Filament.id)))
  291. info["database"]["filaments_total"] = result.scalar() or 0
  292. result = await db.execute(select(func.count(Project.id)))
  293. info["database"]["projects_total"] = result.scalar() or 0
  294. result = await db.execute(select(func.count(SmartPlug.id)))
  295. info["database"]["smart_plugs_total"] = result.scalar() or 0
  296. # Printer info (anonymized - just models and connection status)
  297. result = await db.execute(select(Printer))
  298. printers = result.scalars().all()
  299. for i, printer in enumerate(printers):
  300. info["printers"].append(
  301. {
  302. "index": i + 1,
  303. "model": printer.model or "Unknown",
  304. "nozzle_count": printer.nozzle_count,
  305. }
  306. )
  307. # Non-sensitive settings
  308. result = await db.execute(select(Settings))
  309. all_settings = result.scalars().all()
  310. sensitive_keys = {
  311. "access_code",
  312. "password",
  313. "token",
  314. "secret",
  315. "api_key",
  316. "installation_id",
  317. "cloud_token",
  318. "mqtt_password",
  319. "email",
  320. "vapid",
  321. "private_key",
  322. "public_key",
  323. "webhook",
  324. "url",
  325. "config", # URLs may contain IPs, configs may have embedded secrets
  326. }
  327. for s in all_settings:
  328. # Skip sensitive settings
  329. if any(sensitive in s.key.lower() for sensitive in sensitive_keys):
  330. continue
  331. info["settings"][s.key] = s.value
  332. return info
  333. def _sanitize_log_content(content: str) -> str:
  334. """Remove sensitive data from log content."""
  335. import re
  336. # Replace IP addresses with [IP]
  337. content = re.sub(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", "[IP]", content)
  338. # Replace email addresses
  339. content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
  340. # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
  341. # These appear in logs as [SERIAL] or in messages
  342. content = re.sub(r"\b(0[0-3][A-Z0-9])[A-Z0-9]{9,13}\b", r"\1[SERIAL]", content)
  343. # Replace paths with usernames
  344. content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
  345. content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content)
  346. content = re.sub(r"/opt/[^/\s]+/", "/opt/[user]/", content)
  347. return content
  348. def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
  349. """Get log file content, limited to max_bytes from the end."""
  350. log_file = settings.log_dir / "bambuddy.log"
  351. if not log_file.exists():
  352. return b"Log file not found"
  353. file_size = log_file.stat().st_size
  354. if file_size <= max_bytes:
  355. content = log_file.read_text(encoding="utf-8", errors="replace")
  356. else:
  357. # Read last max_bytes
  358. with open(log_file, "rb") as f:
  359. f.seek(file_size - max_bytes)
  360. # Skip partial line at start
  361. f.readline()
  362. content = f.read().decode("utf-8", errors="replace")
  363. # Sanitize sensitive data
  364. content = _sanitize_log_content(content)
  365. return content.encode("utf-8")
  366. @router.get("/bundle")
  367. async def generate_support_bundle(
  368. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  369. ):
  370. """Generate a support bundle ZIP file for issue reporting."""
  371. global _debug_logging_enabled, _debug_logging_enabled_at
  372. # Check if debug logging is enabled
  373. async with async_session() as db:
  374. enabled, enabled_at = await _get_debug_setting(db)
  375. _debug_logging_enabled = enabled
  376. _debug_logging_enabled_at = enabled_at
  377. if not enabled:
  378. raise HTTPException(
  379. status_code=400,
  380. detail="Debug logging must be enabled before generating a support bundle. "
  381. "Please enable debug logging, reproduce the issue, then generate the bundle.",
  382. )
  383. # Collect support info
  384. support_info = await _collect_support_info()
  385. # Create ZIP in memory
  386. zip_buffer = io.BytesIO()
  387. timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
  388. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  389. # Add support info JSON
  390. zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
  391. # Add log file
  392. log_content = _get_log_content()
  393. zf.writestr("bambuddy.log", log_content)
  394. zip_buffer.seek(0)
  395. filename = f"bambuddy-support-{timestamp}.zip"
  396. logger.info("Generated support bundle: %s", filename)
  397. return StreamingResponse(
  398. zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"}
  399. )
  400. async def init_debug_logging():
  401. """Initialize debug logging state from database on startup."""
  402. global _debug_logging_enabled, _debug_logging_enabled_at
  403. try:
  404. async with async_session() as db:
  405. enabled, enabled_at = await _get_debug_setting(db)
  406. _debug_logging_enabled = enabled
  407. _debug_logging_enabled_at = enabled_at
  408. if enabled:
  409. _apply_log_level(True)
  410. logger.info("Debug logging restored from previous session")
  411. except Exception as e:
  412. logger.warning("Could not restore debug logging state: %s", e)