support.py 18 KB

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