support.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903
  1. """Support endpoints for debug logging and support bundle generation."""
  2. import asyncio
  3. import importlib.metadata
  4. import io
  5. import ipaddress
  6. import json
  7. import logging
  8. import os
  9. import platform
  10. import re
  11. import zipfile
  12. from datetime import datetime, timezone
  13. from pathlib import Path
  14. from fastapi import APIRouter, HTTPException, Query
  15. from fastapi.responses import StreamingResponse
  16. from pydantic import BaseModel
  17. from sqlalchemy import func, select, text
  18. from sqlalchemy.ext.asyncio import AsyncSession
  19. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  20. from backend.app.core.config import APP_VERSION, settings
  21. from backend.app.core.database import async_session
  22. from backend.app.core.permissions import Permission
  23. from backend.app.core.websocket import ws_manager
  24. from backend.app.models.archive import PrintArchive
  25. from backend.app.models.filament import Filament
  26. from backend.app.models.notification import NotificationProvider
  27. from backend.app.models.printer import Printer
  28. from backend.app.models.project import Project
  29. from backend.app.models.settings import Settings
  30. from backend.app.models.smart_plug import SmartPlug
  31. from backend.app.models.user import User
  32. from backend.app.services.discovery import is_running_in_docker
  33. from backend.app.services.network_utils import get_network_interfaces
  34. from backend.app.services.printer_manager import printer_manager
  35. router = APIRouter(prefix="/support", tags=["support"])
  36. logger = logging.getLogger(__name__)
  37. class DebugLoggingState(BaseModel):
  38. enabled: bool
  39. enabled_at: str | None = None
  40. duration_seconds: int | None = None
  41. class DebugLoggingToggle(BaseModel):
  42. enabled: bool
  43. async def _get_debug_setting(db: AsyncSession) -> tuple[bool, datetime | None]:
  44. """Get debug logging state from database."""
  45. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
  46. enabled_setting = result.scalar_one_or_none()
  47. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
  48. enabled_at_setting = result.scalar_one_or_none()
  49. enabled = enabled_setting.value.lower() == "true" if enabled_setting else False
  50. enabled_at = None
  51. if enabled_at_setting and enabled_at_setting.value:
  52. try:
  53. <<<<<<< HEAD
  54. enabled_at = datetime.fromisoformat(enabled_at_setting.value).replace(tzinfo=None)
  55. =======
  56. enabled_at = datetime.fromisoformat(enabled_at_setting.value)
  57. if enabled_at.tzinfo is None:
  58. enabled_at = enabled_at.replace(tzinfo=timezone.utc)
  59. >>>>>>> 63208cf ( Fix debug logging banner showing negative timer duration)
  60. except ValueError:
  61. pass # Ignore malformed timestamp; enabled_at stays None
  62. return enabled, enabled_at
  63. async def _set_debug_setting(db: AsyncSession, enabled: bool) -> datetime | None:
  64. """Set debug logging state in database."""
  65. # Update or create enabled setting
  66. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
  67. setting = result.scalar_one_or_none()
  68. if setting:
  69. setting.value = str(enabled).lower()
  70. else:
  71. db.add(Settings(key="debug_logging_enabled", value=str(enabled).lower()))
  72. # Update enabled_at timestamp
  73. enabled_at = datetime.now(tz=timezone.utc) if enabled else None
  74. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
  75. at_setting = result.scalar_one_or_none()
  76. if at_setting:
  77. at_setting.value = enabled_at.isoformat() if enabled_at else ""
  78. else:
  79. db.add(Settings(key="debug_logging_enabled_at", value=enabled_at.isoformat() if enabled_at else ""))
  80. await db.commit()
  81. return enabled_at
  82. def _apply_log_level(debug: bool):
  83. """Apply log level change to root logger."""
  84. root_logger = logging.getLogger()
  85. new_level = logging.DEBUG if debug else logging.INFO
  86. root_logger.setLevel(new_level)
  87. for handler in root_logger.handlers:
  88. handler.setLevel(new_level)
  89. # Also adjust third-party loggers
  90. if debug:
  91. logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
  92. logging.getLogger("aiosqlite").setLevel(logging.WARNING)
  93. logging.getLogger("httpcore").setLevel(logging.DEBUG)
  94. logging.getLogger("httpx").setLevel(logging.DEBUG)
  95. logging.getLogger("paho.mqtt").setLevel(logging.DEBUG)
  96. else:
  97. logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
  98. logging.getLogger("httpcore").setLevel(logging.WARNING)
  99. logging.getLogger("httpx").setLevel(logging.WARNING)
  100. logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
  101. logger.info("Log level changed to %s", "DEBUG" if debug else "INFO")
  102. @router.get("/debug-logging", response_model=DebugLoggingState)
  103. async def get_debug_logging_state(
  104. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  105. ):
  106. """Get current debug logging state."""
  107. async with async_session() as db:
  108. enabled, enabled_at = await _get_debug_setting(db)
  109. duration = None
  110. if enabled and enabled_at:
  111. duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())
  112. return DebugLoggingState(
  113. enabled=enabled,
  114. enabled_at=enabled_at.isoformat() if enabled_at else None,
  115. duration_seconds=duration,
  116. )
  117. @router.post("/debug-logging", response_model=DebugLoggingState)
  118. async def toggle_debug_logging(
  119. toggle: DebugLoggingToggle,
  120. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  121. ):
  122. """Enable or disable debug logging."""
  123. async with async_session() as db:
  124. enabled_at = await _set_debug_setting(db, toggle.enabled)
  125. _apply_log_level(toggle.enabled)
  126. duration = None
  127. if toggle.enabled and enabled_at:
  128. duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())
  129. return DebugLoggingState(
  130. enabled=toggle.enabled,
  131. enabled_at=enabled_at.isoformat() if enabled_at else None,
  132. duration_seconds=duration,
  133. )
  134. class LogEntry(BaseModel):
  135. """A single log entry."""
  136. timestamp: str
  137. level: str
  138. logger_name: str
  139. message: str
  140. class LogsResponse(BaseModel):
  141. """Response containing log entries."""
  142. entries: list[LogEntry]
  143. total_in_file: int
  144. filtered_count: int
  145. # Log line regex pattern: "2024-01-15 10:30:45,123 INFO [module.name] Message here"
  146. 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+(.*)$")
  147. def _parse_log_line(line: str) -> LogEntry | None:
  148. """Parse a single log line into a LogEntry."""
  149. match = LOG_LINE_PATTERN.match(line.strip())
  150. if match:
  151. return LogEntry(
  152. timestamp=match.group(1),
  153. level=match.group(2),
  154. logger_name=match.group(3),
  155. message=match.group(4),
  156. )
  157. return None
  158. def _read_log_entries(
  159. limit: int = 200,
  160. level_filter: str | None = None,
  161. search: str | None = None,
  162. ) -> tuple[list[LogEntry], int]:
  163. """Read and parse log entries from file with optional filtering."""
  164. log_file = settings.log_dir / "bambuddy.log"
  165. if not log_file.exists():
  166. return [], 0
  167. entries: list[LogEntry] = []
  168. total_lines = 0
  169. try:
  170. with open(log_file, encoding="utf-8", errors="replace") as f:
  171. # Read all lines and process
  172. lines = f.readlines()
  173. total_lines = len(lines)
  174. # Parse lines in reverse order (newest first)
  175. current_entry: LogEntry | None = None
  176. multi_line_buffer: list[str] = []
  177. for line in reversed(lines):
  178. parsed = _parse_log_line(line)
  179. if parsed:
  180. # Found a new log entry start
  181. if current_entry:
  182. # Apply filters and add previous entry (without multi_line_buffer - it belongs to new entry)
  183. should_include = True
  184. # Level filter
  185. if level_filter and current_entry.level.upper() != level_filter.upper():
  186. should_include = False
  187. # Search filter (case-insensitive)
  188. if search and should_include:
  189. search_lower = search.lower()
  190. if not (
  191. search_lower in current_entry.message.lower()
  192. or search_lower in current_entry.logger_name.lower()
  193. ):
  194. should_include = False
  195. if should_include:
  196. entries.append(current_entry)
  197. if len(entries) >= limit:
  198. break
  199. # Set new entry and attach any accumulated multi-line content to it
  200. # (in reverse order, continuation lines come before their parent entry)
  201. current_entry = parsed
  202. if multi_line_buffer:
  203. current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
  204. multi_line_buffer = []
  205. elif line.strip():
  206. # Continuation of multi-line log entry (will be attached to next parsed entry)
  207. multi_line_buffer.append(line.rstrip())
  208. # Don't forget the last (oldest) entry
  209. # Note: any remaining multi_line_buffer would be orphaned lines before the first entry
  210. if current_entry and len(entries) < limit:
  211. should_include = True
  212. if level_filter and current_entry.level.upper() != level_filter.upper():
  213. should_include = False
  214. if search and should_include:
  215. search_lower = search.lower()
  216. if not (
  217. search_lower in current_entry.message.lower()
  218. or search_lower in current_entry.logger_name.lower()
  219. ):
  220. should_include = False
  221. if should_include:
  222. entries.append(current_entry)
  223. except Exception as e:
  224. logger.error("Error reading log file: %s", e)
  225. return [], 0
  226. # Entries are already in newest-first order
  227. return entries, total_lines
  228. @router.get("/logs", response_model=LogsResponse)
  229. async def get_logs(
  230. limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
  231. level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
  232. search: str | None = Query(None, description="Search in message or logger name"),
  233. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  234. ):
  235. """Get recent application log entries with optional filtering."""
  236. entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
  237. return LogsResponse(
  238. entries=entries,
  239. total_in_file=total_lines,
  240. filtered_count=len(entries),
  241. )
  242. @router.delete("/logs")
  243. async def clear_logs(
  244. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  245. ):
  246. """Clear the application log file."""
  247. log_file = settings.log_dir / "bambuddy.log"
  248. if log_file.exists():
  249. try:
  250. # Truncate the file instead of deleting (keeps file handles valid)
  251. with open(log_file, "w", encoding="utf-8") as f:
  252. f.write("")
  253. logger.info("Log file cleared by user")
  254. return {"message": "Logs cleared successfully"}
  255. except Exception as e:
  256. logger.error("Error clearing log file: %s", e, exc_info=True)
  257. raise HTTPException(status_code=500, detail="Failed to clear logs. Check server logs for details.")
  258. return {"message": "Log file does not exist"}
  259. def _sanitize_path(path: str) -> str:
  260. """Remove username from paths for privacy."""
  261. # Replace /home/username/ or /Users/username/ with /home/[user]/
  262. path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)
  263. path = re.sub(r"/Users/[^/]+/", "/Users/[user]/", path)
  264. # Replace /opt/username/ patterns
  265. path = re.sub(r"/opt/[^/]+/", "/opt/[user]/", path)
  266. return path
  267. def _detect_docker_network_mode() -> str:
  268. """Detect Docker network mode by checking for host-level interfaces.
  269. In host mode the container shares the host network namespace, so Docker
  270. infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
  271. mode the container is isolated and only sees its own veth (named eth0).
  272. """
  273. try:
  274. import socket
  275. for _idx, name in socket.if_nameindex():
  276. if name.startswith(("docker", "br-", "veth", "virbr")):
  277. return "host"
  278. except Exception:
  279. pass
  280. return "bridge"
  281. def _mask_subnet(subnet: str) -> str:
  282. """Mask the first two octets of a subnet string. e.g. '192.168.1.0/24' -> 'x.x.1.0/24'."""
  283. try:
  284. parts = subnet.split(".")
  285. if len(parts) >= 4:
  286. parts[0] = "x"
  287. parts[1] = "x"
  288. return ".".join(parts)
  289. except Exception:
  290. pass
  291. return subnet
  292. def _anonymize_mqtt_broker(broker: str) -> str:
  293. """Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain."""
  294. if not broker:
  295. return ""
  296. try:
  297. ipaddress.ip_address(broker)
  298. return "[IP]"
  299. except ValueError:
  300. # It's a hostname — show *.domain pattern
  301. parts = broker.split(".")
  302. if len(parts) >= 2:
  303. return "*." + ".".join(parts[-2:])
  304. return broker
  305. async def _check_port(ip: str, port: int, timeout: float = 2.0) -> bool:
  306. """Test TCP connectivity to ip:port. Returns True if reachable."""
  307. try:
  308. _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  309. writer.close()
  310. await writer.wait_closed()
  311. return True
  312. except Exception:
  313. return False
  314. def _get_container_memory_limit() -> int | None:
  315. """Read cgroup memory limit. Returns bytes or None."""
  316. # cgroup v2
  317. v2 = Path("/sys/fs/cgroup/memory.max")
  318. if v2.exists():
  319. try:
  320. val = v2.read_text().strip()
  321. if val != "max":
  322. return int(val)
  323. except Exception:
  324. pass
  325. # cgroup v1
  326. v1 = Path("/sys/fs/cgroup/memory/memory.limit_in_bytes")
  327. if v1.exists():
  328. try:
  329. val = int(v1.read_text().strip())
  330. # Values near page-aligned max (2^63-4096) mean unlimited
  331. if val < 2**62:
  332. return val
  333. except Exception:
  334. pass
  335. return None
  336. def _format_bytes(size_bytes: int) -> str:
  337. """Format bytes into human-readable string."""
  338. if size_bytes < 1024:
  339. return f"{size_bytes} B"
  340. if size_bytes < 1024 * 1024:
  341. return f"{size_bytes / 1024:.1f} KB"
  342. if size_bytes < 1024 * 1024 * 1024:
  343. return f"{size_bytes / (1024 * 1024):.1f} MB"
  344. return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
  345. async def _collect_support_info() -> dict:
  346. """Collect all support information."""
  347. in_docker = is_running_in_docker()
  348. info = {
  349. "generated_at": datetime.now().isoformat(),
  350. "app": {
  351. "version": APP_VERSION,
  352. "debug_mode": settings.debug,
  353. },
  354. "system": {
  355. "platform": platform.system(),
  356. "platform_release": platform.release(),
  357. "platform_version": platform.version(),
  358. "architecture": platform.machine(),
  359. "python_version": platform.python_version(),
  360. },
  361. "environment": {
  362. "docker": in_docker,
  363. "data_dir": _sanitize_path(str(settings.base_dir)),
  364. "log_dir": _sanitize_path(str(settings.log_dir)),
  365. "timezone": os.environ.get("TZ", ""),
  366. },
  367. "database": {},
  368. "printers": [],
  369. "settings": {},
  370. }
  371. # Docker-specific info
  372. if in_docker:
  373. try:
  374. mem_limit = _get_container_memory_limit()
  375. info["docker"] = {
  376. "container_memory_limit_bytes": mem_limit,
  377. "container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None,
  378. "network_mode_hint": _detect_docker_network_mode(),
  379. }
  380. except Exception:
  381. logger.debug("Failed to collect Docker info", exc_info=True)
  382. async with async_session() as db:
  383. # Database stats
  384. result = await db.execute(select(func.count(PrintArchive.id)))
  385. info["database"]["archives_total"] = result.scalar() or 0
  386. result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
  387. info["database"]["archives_completed"] = result.scalar() or 0
  388. result = await db.execute(select(func.count(Printer.id)))
  389. info["database"]["printers_total"] = result.scalar() or 0
  390. result = await db.execute(select(func.count(Filament.id)))
  391. info["database"]["filaments_total"] = result.scalar() or 0
  392. result = await db.execute(select(func.count(Project.id)))
  393. info["database"]["projects_total"] = result.scalar() or 0
  394. result = await db.execute(select(func.count(SmartPlug.id)))
  395. info["database"]["smart_plugs_total"] = result.scalar() or 0
  396. # Printer info (anonymized - no names, IPs, or serials)
  397. result = await db.execute(select(Printer))
  398. printers = result.scalars().all()
  399. statuses = printer_manager.get_all_statuses()
  400. # Check reachability in parallel
  401. reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
  402. reachable_results = await asyncio.gather(*reachability_tasks, return_exceptions=True)
  403. for i, printer in enumerate(printers):
  404. state = statuses.get(printer.id)
  405. reachable = reachable_results[i] if not isinstance(reachable_results[i], Exception) else False
  406. # Count AMS units and trays from raw_data
  407. ams_unit_count = 0
  408. ams_tray_count = 0
  409. has_vt_tray = False
  410. if state:
  411. ams_data = state.raw_data.get("ams")
  412. if isinstance(ams_data, list):
  413. ams_units = ams_data
  414. elif isinstance(ams_data, dict) and "ams" in ams_data:
  415. ams_units = ams_data["ams"] if isinstance(ams_data["ams"], list) else []
  416. else:
  417. ams_units = []
  418. ams_unit_count = len(ams_units)
  419. for unit in ams_units:
  420. trays = unit.get("tray", [])
  421. ams_tray_count += len([t for t in trays if t.get("tray_type")])
  422. has_vt_tray = bool(state.raw_data.get("vt_tray"))
  423. info["printers"].append(
  424. {
  425. "index": i + 1,
  426. "model": printer.model or "Unknown",
  427. "nozzle_count": printer.nozzle_count,
  428. "is_active": printer.is_active,
  429. "mqtt_connected": state.connected if state else False,
  430. "state": state.state if state else "unknown",
  431. "firmware_version": state.firmware_version if state else None,
  432. "wifi_signal": state.wifi_signal if state else None,
  433. "reachable": bool(reachable),
  434. "ams_unit_count": ams_unit_count,
  435. "ams_tray_count": ams_tray_count,
  436. "has_vt_tray": has_vt_tray,
  437. "external_camera_configured": bool(printer.external_camera_url),
  438. "plate_detection_enabled": printer.plate_detection_enabled,
  439. "hms_error_count": len(state.hms_errors) if state else 0,
  440. "developer_mode": state.developer_mode if state else None,
  441. "nozzle_rack_count": len(state.nozzle_rack) if state else 0,
  442. }
  443. )
  444. # Virtual printers
  445. try:
  446. from backend.app.models.virtual_printer import VirtualPrinter
  447. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  448. result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.id))
  449. vps = result.scalars().all()
  450. info["virtual_printers"] = []
  451. for vp in vps:
  452. instance = virtual_printer_manager.get_instance(vp.id)
  453. status = instance.get_status() if instance else None
  454. model_code = vp.model or "C12"
  455. info["virtual_printers"].append(
  456. {
  457. "index": vp.id,
  458. "enabled": vp.enabled,
  459. "mode": vp.mode,
  460. "model": model_code,
  461. "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
  462. "has_target_printer": vp.target_printer_id is not None,
  463. "has_bind_ip": bool(vp.bind_ip),
  464. "running": status.get("running", False) if status else False,
  465. "pending_files": status.get("pending_files", 0) if status else 0,
  466. }
  467. )
  468. except Exception:
  469. logger.debug("Failed to collect virtual printer info", exc_info=True)
  470. # Non-sensitive settings
  471. result = await db.execute(select(Settings))
  472. all_settings = result.scalars().all()
  473. sensitive_keys = {
  474. "access_code",
  475. "password",
  476. "token",
  477. "secret",
  478. "api_key",
  479. "installation_id",
  480. "cloud_token",
  481. "mqtt_password",
  482. "email",
  483. "username",
  484. "vapid",
  485. "private_key",
  486. "public_key",
  487. "webhook",
  488. "url",
  489. "path", # Filesystem paths may contain usernames
  490. "config", # URLs may contain IPs, configs may have embedded secrets
  491. }
  492. for s in all_settings:
  493. # Skip sensitive settings
  494. if any(sensitive in s.key.lower() for sensitive in sensitive_keys):
  495. continue
  496. info["settings"][s.key] = s.value
  497. # Notification providers (anonymized — type/enabled/error status only)
  498. try:
  499. result = await db.execute(select(NotificationProvider))
  500. providers = result.scalars().all()
  501. info["integrations"] = info.get("integrations", {})
  502. info["integrations"]["notification_providers"] = [
  503. {
  504. "type": p.provider_type,
  505. "enabled": p.enabled,
  506. "has_last_error": bool(p.last_error),
  507. }
  508. for p in providers
  509. ]
  510. except Exception:
  511. logger.debug("Failed to collect notification provider info", exc_info=True)
  512. # Database health
  513. try:
  514. result = await db.execute(text("PRAGMA journal_mode"))
  515. journal_mode = result.scalar()
  516. result = await db.execute(text("PRAGMA quick_check"))
  517. quick_check = result.scalar()
  518. db_path = settings.base_dir / "bambuddy.db"
  519. db_size = db_path.stat().st_size if db_path.exists() else 0
  520. wal_path = settings.base_dir / "bambuddy.db-wal"
  521. wal_size = wal_path.stat().st_size if wal_path.exists() else 0
  522. info["database_health"] = {
  523. "journal_mode": journal_mode,
  524. "quick_check": quick_check,
  525. "db_size_bytes": db_size,
  526. "wal_size_bytes": wal_size,
  527. }
  528. except Exception:
  529. logger.debug("Failed to collect database health info", exc_info=True)
  530. # Integrations (lazy imports to avoid circular dependencies)
  531. info.setdefault("integrations", {})
  532. # Spoolman
  533. try:
  534. from backend.app.services.spoolman import get_spoolman_client
  535. client = await get_spoolman_client()
  536. if client:
  537. reachable = await client.health_check()
  538. info["integrations"]["spoolman"] = {"enabled": True, "reachable": reachable}
  539. else:
  540. info["integrations"]["spoolman"] = {"enabled": False, "reachable": False}
  541. except Exception:
  542. logger.debug("Failed to collect Spoolman info", exc_info=True)
  543. # MQTT relay
  544. try:
  545. from backend.app.services.mqtt_relay import mqtt_relay
  546. status = mqtt_relay.get_status()
  547. info["integrations"]["mqtt_relay"] = {
  548. "enabled": status.get("enabled", False),
  549. "connected": status.get("connected", False),
  550. "broker": _anonymize_mqtt_broker(status.get("broker", "")),
  551. "port": status.get("port", 0),
  552. "topic_prefix": status.get("topic_prefix", ""),
  553. }
  554. except Exception:
  555. logger.debug("Failed to collect MQTT relay info", exc_info=True)
  556. # Home Assistant (check ha_enabled setting)
  557. try:
  558. info["integrations"]["homeassistant"] = {
  559. "enabled": info["settings"].get("ha_enabled", "false").lower() == "true",
  560. }
  561. except Exception:
  562. logger.debug("Failed to collect Home Assistant info", exc_info=True)
  563. # Dependencies
  564. try:
  565. dep_packages = [
  566. "fastapi",
  567. "uvicorn",
  568. "pydantic",
  569. "sqlalchemy",
  570. "paho-mqtt",
  571. "psutil",
  572. "httpx",
  573. "aiofiles",
  574. "cryptography",
  575. "opencv-python-headless",
  576. "numpy",
  577. ]
  578. info["dependencies"] = {}
  579. for pkg in dep_packages:
  580. try:
  581. info["dependencies"][pkg] = importlib.metadata.version(pkg)
  582. except importlib.metadata.PackageNotFoundError:
  583. info["dependencies"][pkg] = None
  584. except Exception:
  585. logger.debug("Failed to collect dependency info", exc_info=True)
  586. # Log file info
  587. try:
  588. log_file = settings.log_dir / "bambuddy.log"
  589. if log_file.exists():
  590. size = log_file.stat().st_size
  591. info["log_file"] = {
  592. "size_bytes": size,
  593. "size_formatted": _format_bytes(size),
  594. }
  595. else:
  596. info["log_file"] = {"size_bytes": 0, "size_formatted": "0 B"}
  597. except Exception:
  598. logger.debug("Failed to collect log file info", exc_info=True)
  599. # Network interfaces (subnets with first two octets masked)
  600. try:
  601. interfaces = get_network_interfaces()
  602. info["network"] = {
  603. "interface_count": len(interfaces),
  604. "interfaces": [{"name": iface["name"], "subnet": _mask_subnet(iface["subnet"])} for iface in interfaces],
  605. }
  606. except Exception:
  607. logger.debug("Failed to collect network info", exc_info=True)
  608. # WebSocket connections
  609. try:
  610. info["websockets"] = {
  611. "active_connections": len(ws_manager.active_connections),
  612. }
  613. except Exception:
  614. logger.debug("Failed to collect WebSocket info", exc_info=True)
  615. return info
  616. def _sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None = None) -> str:
  617. """Remove sensitive data from log content."""
  618. # First, replace known sensitive values (database-aware exact matching)
  619. # This catches printer names, usernames, and other arbitrary user-chosen strings
  620. # that regex patterns cannot detect
  621. if sensitive_strings:
  622. # Sort by length descending to avoid partial matches (e.g. "My Printer 1" before "My Printer")
  623. for value, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
  624. if len(value) < 3:
  625. continue # Skip very short strings to prevent over-redaction
  626. content = re.sub(re.escape(value), label, content)
  627. # Replace credentials in URLs (e.g. http://user:pass@host)
  628. content = re.sub(r"(https?://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
  629. # Replace email addresses
  630. content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
  631. # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
  632. content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
  633. # Replace IPv4 addresses (skip firmware versions like 01.09.01.00 which have leading zeros)
  634. content = re.sub(
  635. r"\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\b",
  636. "[IP]",
  637. content,
  638. )
  639. # Replace paths with usernames
  640. content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
  641. content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content)
  642. content = re.sub(r"/opt/[^/\s]+/", "/opt/[user]/", content)
  643. return content
  644. def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[str, str] | None = None) -> bytes:
  645. """Get log file content, limited to max_bytes from the end."""
  646. log_file = settings.log_dir / "bambuddy.log"
  647. if not log_file.exists():
  648. return b"Log file not found"
  649. file_size = log_file.stat().st_size
  650. if file_size <= max_bytes:
  651. content = log_file.read_text(encoding="utf-8", errors="replace")
  652. else:
  653. # Read last max_bytes
  654. with open(log_file, "rb") as f:
  655. f.seek(file_size - max_bytes)
  656. # Skip partial line at start
  657. f.readline()
  658. content = f.read().decode("utf-8", errors="replace")
  659. # Sanitize sensitive data
  660. content = _sanitize_log_content(content, sensitive_strings)
  661. return content.encode("utf-8")
  662. async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
  663. """Get recent log lines, sanitized for inclusion in bug reports."""
  664. # Collect sensitive strings from DB for redaction
  665. sensitive_strings: dict[str, str] = {}
  666. async with async_session() as db:
  667. result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address))
  668. for name, serial, ip_address in result.all():
  669. if name:
  670. sensitive_strings[name] = "[PRINTER]"
  671. if serial:
  672. sensitive_strings[serial] = "[SERIAL]"
  673. if ip_address:
  674. sensitive_strings[ip_address] = "[IP]"
  675. result = await db.execute(select(User.username))
  676. for (username,) in result.all():
  677. if username:
  678. sensitive_strings[username] = "[USER]"
  679. result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
  680. cloud_email = result.scalar_one_or_none()
  681. if cloud_email:
  682. sensitive_strings[cloud_email] = "[EMAIL]"
  683. log_file = settings.log_dir / "bambuddy.log"
  684. if not log_file.exists():
  685. return ""
  686. # Read last portion of log file
  687. try:
  688. content = log_file.read_text(encoding="utf-8", errors="replace")
  689. lines = content.splitlines()
  690. recent = "\n".join(lines[-max_lines:])
  691. return _sanitize_log_content(recent, sensitive_strings)
  692. except Exception:
  693. logger.debug("Failed to read logs for bug report", exc_info=True)
  694. return ""
  695. @router.get("/bundle")
  696. async def generate_support_bundle(
  697. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  698. ):
  699. """Generate a support bundle ZIP file for issue reporting."""
  700. # Check if debug logging is enabled and collect sensitive values for redaction
  701. async with async_session() as db:
  702. enabled, _enabled_at = await _get_debug_setting(db)
  703. if not enabled:
  704. raise HTTPException(
  705. status_code=400,
  706. detail="Debug logging must be enabled before generating a support bundle. "
  707. "Please enable debug logging, reproduce the issue, then generate the bundle.",
  708. )
  709. # Collect known sensitive values for log redaction
  710. sensitive_strings: dict[str, str] = {}
  711. # Printer names, serial numbers, and IP addresses
  712. result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address))
  713. for name, serial, ip_address in result.all():
  714. if name:
  715. sensitive_strings[name] = "[PRINTER]"
  716. if serial:
  717. sensitive_strings[serial] = "[SERIAL]"
  718. if ip_address:
  719. sensitive_strings[ip_address] = "[IP]"
  720. # Auth usernames
  721. result = await db.execute(select(User.username))
  722. for (username,) in result.all():
  723. if username:
  724. sensitive_strings[username] = "[USER]"
  725. # Bambu Cloud email
  726. result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
  727. cloud_email = result.scalar_one_or_none()
  728. if cloud_email:
  729. sensitive_strings[cloud_email] = "[EMAIL]"
  730. # Collect support info
  731. support_info = await _collect_support_info()
  732. # Create ZIP in memory
  733. zip_buffer = io.BytesIO()
  734. timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
  735. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  736. # Add support info JSON
  737. zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
  738. # Add log file
  739. log_content = _get_log_content(sensitive_strings=sensitive_strings)
  740. zf.writestr("bambuddy.log", log_content)
  741. zip_buffer.seek(0)
  742. filename = f"bambuddy-support-{timestamp}.zip"
  743. logger.info("Generated support bundle: %s", filename)
  744. return StreamingResponse(
  745. zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"}
  746. )
  747. async def init_debug_logging():
  748. """Initialize debug logging state from database on startup."""
  749. try:
  750. async with async_session() as db:
  751. enabled, _ = await _get_debug_setting(db)
  752. if enabled:
  753. _apply_log_level(True)
  754. logger.info("Debug logging restored from previous session")
  755. except Exception as e:
  756. logger.warning("Could not restore debug logging state: %s", e)