support.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218
  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.log_reader import (
  34. LogEntry,
  35. collect_sensitive_strings,
  36. read_log_entries,
  37. sanitize_log_content,
  38. )
  39. from backend.app.services.network_utils import get_network_interfaces
  40. from backend.app.services.printer_manager import printer_manager
  41. router = APIRouter(prefix="/support", tags=["support"])
  42. logger = logging.getLogger(__name__)
  43. class DebugLoggingState(BaseModel):
  44. enabled: bool
  45. enabled_at: str | None = None
  46. duration_seconds: int | None = None
  47. class DebugLoggingToggle(BaseModel):
  48. enabled: bool
  49. async def _get_debug_setting(db: AsyncSession) -> tuple[bool, datetime | None]:
  50. """Get debug logging state from database."""
  51. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
  52. enabled_setting = result.scalar_one_or_none()
  53. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
  54. enabled_at_setting = result.scalar_one_or_none()
  55. enabled = enabled_setting.value.lower() == "true" if enabled_setting else False
  56. enabled_at = None
  57. if enabled_at_setting and enabled_at_setting.value:
  58. try:
  59. enabled_at = datetime.fromisoformat(enabled_at_setting.value)
  60. if enabled_at.tzinfo is None:
  61. enabled_at = enabled_at.replace(tzinfo=timezone.utc)
  62. except ValueError:
  63. pass # Ignore malformed timestamp; enabled_at stays None
  64. return enabled, enabled_at
  65. async def _set_debug_setting(db: AsyncSession, enabled: bool) -> datetime | None:
  66. """Set debug logging state in database."""
  67. # Update or create enabled setting
  68. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled"))
  69. setting = result.scalar_one_or_none()
  70. if setting:
  71. setting.value = str(enabled).lower()
  72. else:
  73. db.add(Settings(key="debug_logging_enabled", value=str(enabled).lower()))
  74. # Update enabled_at timestamp
  75. enabled_at = datetime.now(tz=timezone.utc) if enabled else None
  76. result = await db.execute(select(Settings).where(Settings.key == "debug_logging_enabled_at"))
  77. at_setting = result.scalar_one_or_none()
  78. if at_setting:
  79. at_setting.value = enabled_at.isoformat() if enabled_at else ""
  80. else:
  81. db.add(Settings(key="debug_logging_enabled_at", value=enabled_at.isoformat() if enabled_at else ""))
  82. await db.commit()
  83. return enabled_at
  84. def _apply_log_level(debug: bool):
  85. """Apply log level change to root logger."""
  86. root_logger = logging.getLogger()
  87. new_level = logging.DEBUG if debug else logging.INFO
  88. root_logger.setLevel(new_level)
  89. for handler in root_logger.handlers:
  90. handler.setLevel(new_level)
  91. # Also adjust third-party loggers. httpx/httpcore stay pinned to WARNING
  92. # even in debug mode — at INFO/DEBUG they log full request URLs, which
  93. # leaks secrets embedded in webhook URLs (Discord, generic webhooks, etc.).
  94. logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
  95. logging.getLogger("aiosqlite").setLevel(logging.WARNING)
  96. logging.getLogger("httpcore").setLevel(logging.WARNING)
  97. logging.getLogger("httpx").setLevel(logging.WARNING)
  98. logging.getLogger("paho.mqtt").setLevel(logging.DEBUG if debug else logging.WARNING)
  99. logger.info("Log level changed to %s", "DEBUG" if debug else "INFO")
  100. @router.get("/debug-logging", response_model=DebugLoggingState)
  101. async def get_debug_logging_state(
  102. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  103. ):
  104. """Get current debug logging state."""
  105. async with async_session() as db:
  106. enabled, enabled_at = await _get_debug_setting(db)
  107. duration = None
  108. if enabled and enabled_at:
  109. duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())
  110. return DebugLoggingState(
  111. enabled=enabled,
  112. enabled_at=enabled_at.isoformat() if enabled_at else None,
  113. duration_seconds=duration,
  114. )
  115. @router.post("/debug-logging", response_model=DebugLoggingState)
  116. async def toggle_debug_logging(
  117. toggle: DebugLoggingToggle,
  118. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  119. ):
  120. """Enable or disable debug logging."""
  121. async with async_session() as db:
  122. enabled_at = await _set_debug_setting(db, toggle.enabled)
  123. _apply_log_level(toggle.enabled)
  124. duration = None
  125. if toggle.enabled and enabled_at:
  126. duration = int((datetime.now(tz=timezone.utc) - enabled_at).total_seconds())
  127. return DebugLoggingState(
  128. enabled=toggle.enabled,
  129. enabled_at=enabled_at.isoformat() if enabled_at else None,
  130. duration_seconds=duration,
  131. )
  132. class LogsResponse(BaseModel):
  133. """Response containing log entries."""
  134. entries: list[LogEntry]
  135. total_in_file: int
  136. filtered_count: int
  137. @router.get("/logs", response_model=LogsResponse)
  138. async def get_logs(
  139. limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
  140. level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
  141. search: str | None = Query(None, description="Search in message or logger name"),
  142. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  143. ):
  144. """Get recent application log entries with optional filtering."""
  145. entries, total_lines = read_log_entries(limit=limit, level_filter=level, search=search)
  146. return LogsResponse(
  147. entries=entries,
  148. total_in_file=total_lines,
  149. filtered_count=len(entries),
  150. )
  151. @router.delete("/logs")
  152. async def clear_logs(
  153. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  154. ):
  155. """Clear the application log file."""
  156. log_file = settings.log_dir / "bambuddy.log"
  157. if log_file.exists():
  158. try:
  159. # Truncate the file instead of deleting (keeps file handles valid)
  160. with open(log_file, "w", encoding="utf-8") as f:
  161. f.write("")
  162. logger.info("Log file cleared by user")
  163. return {"message": "Logs cleared successfully"}
  164. except Exception as e:
  165. logger.error("Error clearing log file: %s", e, exc_info=True)
  166. raise HTTPException(status_code=500, detail="Failed to clear logs. Check server logs for details.")
  167. return {"message": "Log file does not exist"}
  168. def _sanitize_path(path: str) -> str:
  169. """Remove username from paths for privacy."""
  170. # Replace /home/username/ or /Users/username/ with /home/[user]/
  171. path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)
  172. path = re.sub(r"/Users/[^/]+/", "/Users/[user]/", path)
  173. # Replace /opt/username/ patterns
  174. path = re.sub(r"/opt/[^/]+/", "/opt/[user]/", path)
  175. return path
  176. def _detect_docker_network_mode() -> str:
  177. """Detect Docker network mode by checking for host-level interfaces.
  178. In host mode the container shares the host network namespace, so Docker
  179. infrastructure interfaces (docker0, br-*, veth*) are visible. In bridge
  180. mode the container is isolated and only sees its own veth (named eth0).
  181. """
  182. try:
  183. import socket
  184. for _idx, name in socket.if_nameindex():
  185. if name.startswith(("docker", "br-", "veth", "virbr")):
  186. return "host"
  187. except Exception:
  188. pass
  189. return "bridge"
  190. def _mask_subnet(subnet: str) -> str:
  191. """Mask the first two octets of a subnet string. e.g. '192.168.1.0/24' -> 'x.x.1.0/24'."""
  192. try:
  193. parts = subnet.split(".")
  194. if len(parts) >= 4:
  195. parts[0] = "x"
  196. parts[1] = "x"
  197. return ".".join(parts)
  198. except Exception:
  199. pass
  200. return subnet
  201. def _anonymize_mqtt_broker(broker: str) -> str:
  202. """Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain."""
  203. if not broker:
  204. return ""
  205. try:
  206. ipaddress.ip_address(broker)
  207. return "[IP]"
  208. except ValueError:
  209. # It's a hostname — show *.domain pattern
  210. parts = broker.split(".")
  211. if len(parts) >= 2:
  212. return "*." + ".".join(parts[-2:])
  213. return broker
  214. async def _check_port(ip: str, port: int, timeout: float = 2.0) -> bool:
  215. """Test TCP connectivity to ip:port. Returns True if reachable."""
  216. try:
  217. _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
  218. writer.close()
  219. await writer.wait_closed()
  220. return True
  221. except Exception:
  222. return False
  223. def _get_container_memory_limit() -> int | None:
  224. """Read cgroup memory limit. Returns bytes or None."""
  225. # cgroup v2
  226. v2 = Path("/sys/fs/cgroup/memory.max")
  227. if v2.exists():
  228. try:
  229. val = v2.read_text().strip()
  230. if val != "max":
  231. return int(val)
  232. except Exception:
  233. pass
  234. # cgroup v1
  235. v1 = Path("/sys/fs/cgroup/memory/memory.limit_in_bytes")
  236. if v1.exists():
  237. try:
  238. val = int(v1.read_text().strip())
  239. # Values near page-aligned max (2^63-4096) mean unlimited
  240. if val < 2**62:
  241. return val
  242. except Exception:
  243. pass
  244. return None
  245. def _format_bytes(size_bytes: int) -> str:
  246. """Format bytes into human-readable string."""
  247. if size_bytes < 1024:
  248. return f"{size_bytes} B"
  249. if size_bytes < 1024 * 1024:
  250. return f"{size_bytes / 1024:.1f} KB"
  251. if size_bytes < 1024 * 1024 * 1024:
  252. return f"{size_bytes / (1024 * 1024):.1f} MB"
  253. return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
  254. async def _collect_auth_info(db: AsyncSession) -> dict:
  255. """Auth-related configuration that's stored OUTSIDE the settings table.
  256. The settings-table passthrough already captures `ldap_*`, `advanced_auth_enabled`,
  257. etc. The blocks below come from dedicated tables that the support bundle did
  258. not previously surface — every recent SSO / 2FA / group bug needed this data
  259. to triage.
  260. """
  261. from backend.app.models.api_key import APIKey
  262. from backend.app.models.group import Group
  263. from backend.app.models.long_lived_token import LongLivedToken
  264. from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
  265. from backend.app.models.user_otp_code import UserOTPCode
  266. from backend.app.models.user_totp import UserTOTP
  267. now = datetime.now(timezone.utc)
  268. auth: dict = {}
  269. # OIDC providers — names are public (login-button labels), no secrets.
  270. providers_result = await db.execute(select(OIDCProvider).order_by(OIDCProvider.id))
  271. providers = providers_result.scalars().all()
  272. oidc_list = []
  273. for p in providers:
  274. # Count linked users per provider — separate query so failure on one
  275. # provider doesn't blank the whole list.
  276. try:
  277. link_count = (
  278. await db.execute(select(func.count(UserOIDCLink.id)).where(UserOIDCLink.provider_id == p.id))
  279. ).scalar() or 0
  280. except Exception:
  281. link_count = None
  282. oidc_list.append(
  283. {
  284. "name": p.name,
  285. "is_enabled": p.is_enabled,
  286. "scopes": p.scopes,
  287. "email_claim": p.email_claim,
  288. "require_email_verified": p.require_email_verified,
  289. "auto_create_users": p.auto_create_users,
  290. "auto_link_existing_accounts": p.auto_link_existing_accounts,
  291. "has_default_group": p.default_group_id is not None,
  292. # Derive from icon_content_type (non-deferred) rather than
  293. # icon_data (deferred BLOB) to avoid an async lazy-load.
  294. # Falls back to icon_url for pre-#1333 rows that have a URL
  295. # configured but no cached bytes yet.
  296. "has_icon": bool(p.icon_content_type) or bool(p.icon_url),
  297. "linked_user_count": link_count,
  298. }
  299. )
  300. auth["oidc_providers"] = oidc_list
  301. # 2FA enrollment — counts only, no per-user data.
  302. totp_enabled = (
  303. await db.execute(select(func.count(UserTOTP.id)).where(UserTOTP.is_enabled.is_(True)))
  304. ).scalar() or 0
  305. auth["users_with_totp"] = totp_enabled
  306. # Active (not-yet-expired, not-yet-used) email OTP codes — bounded count;
  307. # spikes here would point at someone hammering the email OTP flow.
  308. email_otp_pending = (
  309. await db.execute(
  310. select(func.count(UserOTPCode.id)).where(
  311. UserOTPCode.used.is_(False),
  312. UserOTPCode.expires_at > now,
  313. )
  314. )
  315. ).scalar() or 0
  316. auth["email_otp_codes_pending"] = email_otp_pending
  317. # API keys
  318. api_keys_total = (await db.execute(select(func.count(APIKey.id)))).scalar() or 0
  319. api_keys_enabled = (await db.execute(select(func.count(APIKey.id)).where(APIKey.enabled.is_(True)))).scalar() or 0
  320. api_keys_expired = (
  321. await db.execute(
  322. select(func.count(APIKey.id)).where(
  323. APIKey.expires_at.is_not(None),
  324. APIKey.expires_at < now,
  325. )
  326. )
  327. ).scalar() or 0
  328. auth["api_keys_total"] = api_keys_total
  329. auth["api_keys_enabled"] = api_keys_enabled
  330. auth["api_keys_expired"] = api_keys_expired
  331. # Long-lived tokens (camera-stream tokens used by kiosks etc.)
  332. llt_total = (await db.execute(select(func.count(LongLivedToken.id)))).scalar() or 0
  333. llt_active = (
  334. await db.execute(
  335. select(func.count(LongLivedToken.id)).where(
  336. LongLivedToken.revoked_at.is_(None),
  337. LongLivedToken.expires_at > now,
  338. )
  339. )
  340. ).scalar() or 0
  341. auth["long_lived_tokens_total"] = llt_total
  342. auth["long_lived_tokens_active"] = llt_active
  343. # Groups — system vs custom split matters for permission triage.
  344. groups_system = (await db.execute(select(func.count(Group.id)).where(Group.is_system.is_(True)))).scalar() or 0
  345. groups_custom = (await db.execute(select(func.count(Group.id)).where(Group.is_system.is_(False)))).scalar() or 0
  346. auth["groups_system"] = groups_system
  347. auth["groups_custom"] = groups_custom
  348. return auth
  349. async def _collect_library_info(db: AsyncSession) -> dict:
  350. """Library file / folder totals, including external-link and trash counts."""
  351. from backend.app.models.external_link import ExternalLink
  352. from backend.app.models.library import LibraryFile, LibraryFolder
  353. info: dict = {}
  354. info["library_files_total"] = (
  355. await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.deleted_at.is_(None)))
  356. ).scalar() or 0
  357. info["library_files_in_trash"] = (
  358. await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.deleted_at.is_not(None)))
  359. ).scalar() or 0
  360. info["library_folders_total"] = (await db.execute(select(func.count(LibraryFolder.id)))).scalar() or 0
  361. info["external_folders_total"] = (
  362. await db.execute(select(func.count(LibraryFolder.id)).where(LibraryFolder.is_external.is_(True)))
  363. ).scalar() or 0
  364. info["external_links_total"] = (await db.execute(select(func.count(ExternalLink.id)))).scalar() or 0
  365. # MakerWorld imports — counted here because they're LibraryFile rows with
  366. # source_type='makerworld' (the import path doesn't have its own table).
  367. info["makerworld_imports_total"] = (
  368. await db.execute(
  369. select(func.count(LibraryFile.id)).where(
  370. LibraryFile.deleted_at.is_(None),
  371. LibraryFile.source_type == "makerworld",
  372. )
  373. )
  374. ).scalar() or 0
  375. return info
  376. async def _collect_inventory_info(db: AsyncSession) -> dict:
  377. """Spool / k-profile totals from the inventory feature."""
  378. from backend.app.models.spool import Spool
  379. from backend.app.models.spool_k_profile import SpoolKProfile
  380. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  381. info: dict = {}
  382. info["spools_internal"] = (await db.execute(select(func.count(Spool.id)))).scalar() or 0
  383. info["k_profiles_internal"] = (await db.execute(select(func.count(SpoolKProfile.id)))).scalar() or 0
  384. info["k_profiles_spoolman"] = (await db.execute(select(func.count(SpoolmanKProfile.id)))).scalar() or 0
  385. return info
  386. async def _collect_queue_info(db: AsyncSession) -> dict:
  387. """Print-queue health: pending count + oldest pending age."""
  388. from backend.app.models.print_queue import PrintQueueItem
  389. info: dict = {}
  390. info["pending_total"] = (
  391. await db.execute(select(func.count(PrintQueueItem.id)).where(PrintQueueItem.status == "pending"))
  392. ).scalar() or 0
  393. info["manual_start_pending"] = (
  394. await db.execute(
  395. select(func.count(PrintQueueItem.id)).where(
  396. PrintQueueItem.status == "pending",
  397. PrintQueueItem.manual_start.is_(True),
  398. )
  399. )
  400. ).scalar() or 0
  401. # Oldest pending item — derived from created_at to detect items stuck in queue
  402. # (target printer offline, missing filament match, etc.).
  403. oldest_row = (
  404. await db.execute(
  405. select(PrintQueueItem.created_at)
  406. .where(PrintQueueItem.status == "pending")
  407. .order_by(PrintQueueItem.created_at)
  408. .limit(1)
  409. )
  410. ).scalar_one_or_none()
  411. if oldest_row is not None:
  412. # created_at is naive in this codebase (server_default=func.now()); compare
  413. # against naive utc-now to get the actual age without TZ-conversion surprises.
  414. age = (datetime.now() - oldest_row).total_seconds()
  415. info["oldest_pending_age_seconds"] = int(age)
  416. else:
  417. info["oldest_pending_age_seconds"] = None
  418. return info
  419. async def _collect_maintenance_info(db: AsyncSession) -> dict:
  420. """Maintenance schedule totals: enabled items count + last-serviced-never count."""
  421. from backend.app.models.maintenance import PrinterMaintenance
  422. info: dict = {}
  423. info["items_total"] = (await db.execute(select(func.count(PrinterMaintenance.id)))).scalar() or 0
  424. info["items_enabled"] = (
  425. await db.execute(select(func.count(PrinterMaintenance.id)).where(PrinterMaintenance.enabled.is_(True)))
  426. ).scalar() or 0
  427. return info
  428. async def _collect_github_backup_info(db: AsyncSession) -> dict:
  429. """GitHub-backup configs: count per provider + recent-failure indicator."""
  430. from backend.app.models.github_backup import GitHubBackupConfig
  431. rows = (await db.execute(select(GitHubBackupConfig))).scalars().all()
  432. providers_used: dict[str, int] = {}
  433. last_failure_count = 0
  434. schedule_enabled_count = 0
  435. for cfg in rows:
  436. providers_used[cfg.provider] = providers_used.get(cfg.provider, 0) + 1
  437. if cfg.last_backup_status == "failed":
  438. last_failure_count += 1
  439. if cfg.schedule_enabled:
  440. schedule_enabled_count += 1
  441. return {
  442. "configs_total": len(rows),
  443. "providers_used": providers_used,
  444. "schedule_enabled_count": schedule_enabled_count,
  445. "last_failure_count": last_failure_count,
  446. }
  447. async def _check_url_reachable(url: str, timeout: float = 2.0) -> bool | None:
  448. """Single HEAD/GET ping with a short timeout. Returns None if URL is empty."""
  449. if not url or not url.strip():
  450. return None
  451. try:
  452. import httpx
  453. async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501 — local sidecars often use self-signed; this is a reachability/health probe only, no secrets are sent
  454. r = await client.get(url, follow_redirects=False)
  455. # Anything that returned a status code counts as reachable, even 404
  456. # (the API server is up, just the path was wrong) — separates network
  457. # failure from configuration mistakes for the user.
  458. return r.status_code is not None
  459. except Exception:
  460. return False
  461. async def _fetch_slicer_health(url: str, timeout: float = 2.0) -> dict | None:
  462. """Fetch ``/health`` from a slicer sidecar and extract the CLI version.
  463. Returns ``None`` when ``url`` is empty (so the caller can distinguish
  464. "not configured" from "unreachable"). On any failure to fetch or parse,
  465. returns ``{"reachable": False, "version": None}``. The slicer-API wrapper
  466. labels both sidecars' CLI under ``checks.orcaslicer`` regardless of which
  467. slicer is actually bundled (cosmetic wrapper bug), so we read the version
  468. from whichever non-``dataPath`` child key exists rather than hardcoding
  469. one. This lets the bundle reviewer answer "is the user running the image
  470. they think they are?" without a separate curl round-trip.
  471. """
  472. if not url or not url.strip():
  473. return None
  474. health_url = url.rstrip("/") + "/health"
  475. try:
  476. import httpx
  477. async with httpx.AsyncClient(timeout=timeout, verify=False) as client: # nosec B501 — local sidecars often use self-signed; this is a reachability/health probe only, no secrets are sent
  478. r = await client.get(health_url, follow_redirects=False)
  479. if r.status_code != 200:
  480. return {"reachable": True, "version": None}
  481. try:
  482. data = r.json()
  483. except Exception:
  484. return {"reachable": True, "version": None}
  485. checks = data.get("checks") if isinstance(data, dict) else None
  486. if not isinstance(checks, dict):
  487. return {"reachable": True, "version": None}
  488. for key, value in checks.items():
  489. if key == "dataPath":
  490. continue
  491. if isinstance(value, dict) and "version" in value:
  492. return {"reachable": True, "version": value.get("version")}
  493. return {"reachable": True, "version": None}
  494. except Exception:
  495. return {"reachable": False, "version": None}
  496. async def _collect_slicer_api_info() -> dict:
  497. """Reachability check for configured slicer-API sidecars.
  498. Mirrors the URL-resolution precedence used by the real slicer routes
  499. (``archives.py:_slice_for_archive`` and ``library.py``) — DB setting first,
  500. falling back to ``app_settings.bambu_studio_api_url`` / ``slicer_api_url``
  501. which themselves respect the ``BAMBU_STUDIO_API_URL`` / ``SLICER_API_URL``
  502. env vars and default to ``http://localhost:3001`` / ``http://localhost:3003``.
  503. A bundle-time reachability check that only looked at the DB setting would
  504. return ``null`` for every user who runs the sidecar via env var or on the
  505. default port — i.e. most users.
  506. Also reads URLs directly from ``Settings.value`` rather than from
  507. ``info["settings"]``, which has already been redacted by the time the
  508. integrations block runs (``bambu_studio_api_url`` matches the ``url``
  509. keyword filter, so its value there is ``"[REDACTED]"`` and pinging that
  510. crashes httpx).
  511. """
  512. async with async_session() as db:
  513. keys_we_need = (
  514. "use_slicer_api",
  515. "preferred_slicer",
  516. "bambu_studio_api_url",
  517. "orcaslicer_api_url",
  518. )
  519. rows = (await db.execute(select(Settings).where(Settings.key.in_(keys_we_need)))).scalars().all()
  520. raw = {s.key: (s.value or "") for s in rows}
  521. # Resolve with the same DB-then-env-then-default precedence as the route
  522. # that the slicer-API client actually uses, so the bundle reflects what
  523. # the running app would resolve at request time.
  524. bs_db = raw.get("bambu_studio_api_url", "").strip()
  525. oc_db = raw.get("orcaslicer_api_url", "").strip()
  526. bs_url = bs_db or (settings.bambu_studio_api_url or "").strip()
  527. oc_url = oc_db or (settings.slicer_api_url or "").strip()
  528. info: dict = {
  529. "enabled": (raw.get("use_slicer_api", "false") or "false").lower() == "true",
  530. "preferred": raw.get("preferred_slicer", ""),
  531. # Layer accounting helps triage: was the URL set in the DB, or are
  532. # we falling through to the env-var / default? "Reachable but no
  533. # DB setting" is the env-var case.
  534. "bambu_studio_url_set_in_db": bool(bs_db),
  535. "orcaslicer_url_set_in_db": bool(oc_db),
  536. # Effective URL is the resolved one — kept as a host-portion-only
  537. # echo so we can confirm it's the expected sidecar without leaking
  538. # the full URL (which `url` keyword would have redacted anyway).
  539. "bambu_studio_url_source": ("db" if bs_db else ("env_or_default" if bs_url else "unset")),
  540. "orcaslicer_url_source": ("db" if oc_db else ("env_or_default" if oc_url else "unset")),
  541. }
  542. if info["enabled"]:
  543. bs_health, oc_health = await asyncio.gather(
  544. _fetch_slicer_health(bs_url),
  545. _fetch_slicer_health(oc_url),
  546. )
  547. info["bambu_studio_reachable"] = (bs_health or {}).get("reachable") if bs_health is not None else None
  548. info["bambu_studio_version"] = (bs_health or {}).get("version") if bs_health is not None else None
  549. info["orcaslicer_reachable"] = (oc_health or {}).get("reachable") if oc_health is not None else None
  550. info["orcaslicer_version"] = (oc_health or {}).get("version") if oc_health is not None else None
  551. return info
  552. def _parse_obico_enabled_printers(raw: str) -> set[int]:
  553. """Parse the comma-separated `obico_enabled_printers` setting. Same shape as
  554. obico_detection.py uses but tolerant of legacy formats."""
  555. if not raw or not raw.strip():
  556. return set()
  557. result: set[int] = set()
  558. for token in raw.split(","):
  559. token = token.strip()
  560. if not token:
  561. continue
  562. try:
  563. result.add(int(token))
  564. except ValueError:
  565. continue
  566. return result
  567. async def _collect_support_info() -> dict:
  568. """Collect all support information."""
  569. in_docker = is_running_in_docker()
  570. info = {
  571. "generated_at": datetime.now().isoformat(),
  572. "app": {
  573. "version": APP_VERSION,
  574. "debug_mode": settings.debug,
  575. },
  576. "system": {
  577. "platform": platform.system(),
  578. "platform_release": platform.release(),
  579. "platform_version": platform.version(),
  580. "architecture": platform.machine(),
  581. "python_version": platform.python_version(),
  582. },
  583. "environment": {
  584. "docker": in_docker,
  585. "data_dir": _sanitize_path(str(settings.base_dir)),
  586. "log_dir": _sanitize_path(str(settings.log_dir)),
  587. "timezone": os.environ.get("TZ", ""),
  588. },
  589. "database": {},
  590. "printers": [],
  591. "settings": {},
  592. }
  593. # Docker-specific info
  594. if in_docker:
  595. try:
  596. mem_limit = _get_container_memory_limit()
  597. info["docker"] = {
  598. "container_memory_limit_bytes": mem_limit,
  599. "container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None,
  600. "network_mode_hint": _detect_docker_network_mode(),
  601. }
  602. except Exception:
  603. logger.debug("Failed to collect Docker info", exc_info=True)
  604. async with async_session() as db:
  605. # Database stats
  606. result = await db.execute(select(func.count(PrintArchive.id)))
  607. info["database"]["archives_total"] = result.scalar() or 0
  608. result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed"))
  609. info["database"]["archives_completed"] = result.scalar() or 0
  610. result = await db.execute(select(func.count(Printer.id)))
  611. info["database"]["printers_total"] = result.scalar() or 0
  612. result = await db.execute(select(func.count(Filament.id)))
  613. info["database"]["filaments_total"] = result.scalar() or 0
  614. result = await db.execute(select(func.count(Project.id)))
  615. info["database"]["projects_total"] = result.scalar() or 0
  616. result = await db.execute(select(func.count(SmartPlug.id)))
  617. info["database"]["smart_plugs_total"] = result.scalar() or 0
  618. # Printer info (anonymized - no names, IPs, or serials)
  619. result = await db.execute(select(Printer))
  620. printers = result.scalars().all()
  621. statuses = printer_manager.get_all_statuses()
  622. # Pre-load the obico per-printer enabled-list. Settings are loaded later
  623. # in this function (and would overwrite this key in info["settings"]),
  624. # so do a targeted query here for the per-printer flag below.
  625. obico_enabled_set: set[int] = set()
  626. try:
  627. obico_row = (
  628. await db.execute(select(Settings).where(Settings.key == "obico_enabled_printers"))
  629. ).scalar_one_or_none()
  630. if obico_row is not None:
  631. obico_enabled_set = _parse_obico_enabled_printers(obico_row.value)
  632. except Exception:
  633. logger.debug("Failed to load obico_enabled_printers", exc_info=True)
  634. # Check reachability in parallel
  635. reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
  636. reachable_results = await asyncio.gather(*reachability_tasks, return_exceptions=True)
  637. for i, printer in enumerate(printers):
  638. state = statuses.get(printer.id)
  639. reachable = reachable_results[i] if not isinstance(reachable_results[i], Exception) else False
  640. # Count AMS units and trays from raw_data
  641. ams_unit_count = 0
  642. ams_tray_count = 0
  643. has_vt_tray = False
  644. if state:
  645. ams_data = state.raw_data.get("ams")
  646. if isinstance(ams_data, list):
  647. ams_units = ams_data
  648. elif isinstance(ams_data, dict) and "ams" in ams_data:
  649. ams_units = ams_data["ams"] if isinstance(ams_data["ams"], list) else []
  650. else:
  651. ams_units = []
  652. ams_unit_count = len(ams_units)
  653. for unit in ams_units:
  654. trays = unit.get("tray", [])
  655. ams_tray_count += len([t for t in trays if t.get("tray_type")])
  656. has_vt_tray = bool(state.raw_data.get("vt_tray"))
  657. info["printers"].append(
  658. {
  659. "index": i + 1,
  660. "model": printer.model or "Unknown",
  661. "nozzle_count": printer.nozzle_count,
  662. "is_active": printer.is_active,
  663. "mqtt_connected": state.connected if state else False,
  664. "state": state.state if state else "unknown",
  665. "firmware_version": state.firmware_version if state else None,
  666. "wifi_signal": state.wifi_signal if state else None,
  667. "reachable": bool(reachable),
  668. "ams_unit_count": ams_unit_count,
  669. "ams_tray_count": ams_tray_count,
  670. "has_vt_tray": has_vt_tray,
  671. "external_camera_configured": bool(printer.external_camera_url),
  672. "plate_detection_enabled": printer.plate_detection_enabled,
  673. "obico_enabled": printer.id in obico_enabled_set,
  674. "hms_error_count": len(state.hms_errors) if state else 0,
  675. "developer_mode": state.developer_mode if state else None,
  676. "nozzle_rack_count": len(state.nozzle_rack) if state else 0,
  677. }
  678. )
  679. # Virtual printers
  680. try:
  681. from backend.app.models.virtual_printer import VirtualPrinter
  682. from backend.app.services.virtual_printer import VIRTUAL_PRINTER_MODELS, virtual_printer_manager
  683. result = await db.execute(select(VirtualPrinter).order_by(VirtualPrinter.id))
  684. vps = result.scalars().all()
  685. info["virtual_printers"] = []
  686. for vp in vps:
  687. instance = virtual_printer_manager.get_instance(vp.id)
  688. status = instance.get_status() if instance else None
  689. model_code = vp.model or "C12"
  690. info["virtual_printers"].append(
  691. {
  692. "index": vp.id,
  693. "enabled": vp.enabled,
  694. "mode": vp.mode,
  695. "model": model_code,
  696. "model_name": VIRTUAL_PRINTER_MODELS.get(model_code, model_code),
  697. "has_target_printer": vp.target_printer_id is not None,
  698. "has_bind_ip": bool(vp.bind_ip),
  699. "running": status.get("running", False) if status else False,
  700. "pending_files": status.get("pending_files", 0) if status else 0,
  701. }
  702. )
  703. except Exception:
  704. logger.debug("Failed to collect virtual printer info", exc_info=True)
  705. # All settings — sensitive values are redacted rather than dropped so
  706. # new settings automatically show up in support bundles without a code
  707. # change. The value is replaced with "[REDACTED]" but the key is kept
  708. # so we can still see which integrations are configured.
  709. result = await db.execute(select(Settings))
  710. all_settings = result.scalars().all()
  711. sensitive_keys = {
  712. "access_code",
  713. "password",
  714. "token",
  715. "secret",
  716. "api_key",
  717. "auth_key", # Tailscale auth keys: virtual_printer_tailscale_auth_key
  718. "installation_id",
  719. "cloud_token",
  720. "mqtt_password",
  721. "email",
  722. "username",
  723. "vapid",
  724. "private_key",
  725. "public_key",
  726. "webhook",
  727. "url",
  728. "path", # Filesystem paths may contain usernames
  729. "config", # URLs may contain IPs, configs may have embedded secrets
  730. "_ip", # IP address fields (e.g. virtual_printer_remote_interface_ip)
  731. "host",
  732. "broker", # MQTT broker hostname / IP — network exposure
  733. "credential",
  734. }
  735. # Value-based safety net: redact anything whose value carries an
  736. # unambiguous secret prefix, even if the key name didn't match.
  737. # `tskey-` is the Tailscale auth-key prefix — future Tailscale settings
  738. # with unexpected names won't leak just because we forgot to add them.
  739. sensitive_value_prefixes = ("tskey-",)
  740. for s in all_settings:
  741. key_lower = s.key.lower()
  742. value = s.value or ""
  743. if any(sensitive in key_lower for sensitive in sensitive_keys) or any(
  744. value.startswith(prefix) for prefix in sensitive_value_prefixes
  745. ):
  746. # Preserve shape: mark presence without leaking the value
  747. info["settings"][s.key] = "[REDACTED]" if s.value else ""
  748. else:
  749. info["settings"][s.key] = s.value
  750. # Notification providers (anonymized — type/enabled/error status only)
  751. try:
  752. result = await db.execute(select(NotificationProvider))
  753. providers = result.scalars().all()
  754. info["integrations"] = info.get("integrations", {})
  755. info["integrations"]["notification_providers"] = [
  756. {
  757. "type": p.provider_type,
  758. "enabled": p.enabled,
  759. "has_last_error": bool(p.last_error),
  760. }
  761. for p in providers
  762. ]
  763. except Exception:
  764. logger.debug("Failed to collect notification provider info", exc_info=True)
  765. # Database health
  766. try:
  767. from backend.app.core.db_dialect import is_sqlite
  768. if is_sqlite():
  769. result = await db.execute(text("PRAGMA journal_mode"))
  770. journal_mode = result.scalar()
  771. result = await db.execute(text("PRAGMA quick_check"))
  772. quick_check = result.scalar()
  773. db_path = settings.base_dir / "bambuddy.db"
  774. db_size = db_path.stat().st_size if db_path.exists() else 0
  775. wal_path = settings.base_dir / "bambuddy.db-wal"
  776. wal_size = wal_path.stat().st_size if wal_path.exists() else 0
  777. info["database_health"] = {
  778. "backend": "sqlite",
  779. "journal_mode": journal_mode,
  780. "quick_check": quick_check,
  781. "db_size_bytes": db_size,
  782. "wal_size_bytes": wal_size,
  783. }
  784. else:
  785. result = await db.execute(text("SELECT version()"))
  786. pg_version = result.scalar()
  787. result = await db.execute(text("SELECT pg_database_size(current_database())"))
  788. db_size = result.scalar() or 0
  789. info["database_health"] = {
  790. "backend": "postgresql",
  791. "version": pg_version,
  792. "db_size_bytes": db_size,
  793. }
  794. except Exception:
  795. logger.debug("Failed to collect database health info", exc_info=True)
  796. # Auth section — OIDC, 2FA, API keys, long-lived tokens, groups.
  797. # Stored in dedicated tables that the settings-table passthrough doesn't see.
  798. try:
  799. async with async_session() as auth_db:
  800. info["auth"] = await _collect_auth_info(auth_db)
  801. except Exception:
  802. logger.debug("Failed to collect auth info", exc_info=True)
  803. # Library + folder + makerworld import totals
  804. try:
  805. async with async_session() as lib_db:
  806. info["library"] = await _collect_library_info(lib_db)
  807. except Exception:
  808. logger.debug("Failed to collect library info", exc_info=True)
  809. # Spool / k-profile totals (inventory feature)
  810. try:
  811. async with async_session() as inv_db:
  812. info["inventory"] = await _collect_inventory_info(inv_db)
  813. except Exception:
  814. logger.debug("Failed to collect inventory info", exc_info=True)
  815. # Print queue health
  816. try:
  817. async with async_session() as q_db:
  818. info["queue"] = await _collect_queue_info(q_db)
  819. except Exception:
  820. logger.debug("Failed to collect queue info", exc_info=True)
  821. # Maintenance schedules
  822. try:
  823. async with async_session() as m_db:
  824. info["maintenance"] = await _collect_maintenance_info(m_db)
  825. except Exception:
  826. logger.debug("Failed to collect maintenance info", exc_info=True)
  827. # Integrations (lazy imports to avoid circular dependencies)
  828. info.setdefault("integrations", {})
  829. # Spoolman
  830. try:
  831. from backend.app.services.spoolman import get_spoolman_client
  832. client = await get_spoolman_client()
  833. if client:
  834. reachable = await client.health_check()
  835. info["integrations"]["spoolman"] = {"enabled": True, "reachable": reachable}
  836. else:
  837. info["integrations"]["spoolman"] = {"enabled": False, "reachable": False}
  838. except Exception:
  839. logger.debug("Failed to collect Spoolman info", exc_info=True)
  840. # MQTT relay
  841. try:
  842. from backend.app.services.mqtt_relay import mqtt_relay
  843. status = mqtt_relay.get_status()
  844. info["integrations"]["mqtt_relay"] = {
  845. "enabled": status.get("enabled", False),
  846. "connected": status.get("connected", False),
  847. "broker": _anonymize_mqtt_broker(status.get("broker", "")),
  848. "port": status.get("port", 0),
  849. "topic_prefix": status.get("topic_prefix", ""),
  850. }
  851. except Exception:
  852. logger.debug("Failed to collect MQTT relay info", exc_info=True)
  853. # SpoolBuddy devices (anonymized — no hostnames, IPs or device IDs)
  854. try:
  855. async with async_session() as db:
  856. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  857. result = await db.execute(select(SpoolBuddyDevice))
  858. devices = result.scalars().all()
  859. info["integrations"]["spoolbuddy"] = {
  860. "device_count": len(devices),
  861. "online_count": sum(
  862. 1
  863. for d in devices
  864. if d.last_seen
  865. and (datetime.now(tz=timezone.utc) - d.last_seen.replace(tzinfo=timezone.utc)).total_seconds() < 30
  866. ),
  867. "devices": [
  868. {
  869. "index": i + 1,
  870. "firmware_version": d.firmware_version,
  871. "has_nfc": d.has_nfc,
  872. "has_scale": d.has_scale,
  873. "nfc_reader_type": d.nfc_reader_type,
  874. "nfc_connection": d.nfc_connection,
  875. "has_backlight": d.has_backlight,
  876. "nfc_ok": d.nfc_ok,
  877. "scale_ok": d.scale_ok,
  878. "uptime_s": d.uptime_s,
  879. "calibration_factor": d.calibration_factor,
  880. "tare_offset": d.tare_offset,
  881. "last_calibrated_at": d.last_calibrated_at.isoformat() if d.last_calibrated_at else None,
  882. "update_status": d.update_status,
  883. }
  884. for i, d in enumerate(devices)
  885. ],
  886. }
  887. except Exception:
  888. logger.debug("Failed to collect SpoolBuddy info", exc_info=True)
  889. # Home Assistant (check ha_enabled setting)
  890. try:
  891. info["integrations"]["homeassistant"] = {
  892. "enabled": info["settings"].get("ha_enabled", "false").lower() == "true",
  893. }
  894. except Exception:
  895. logger.debug("Failed to collect Home Assistant info", exc_info=True)
  896. # GitHub backup — providers + recent-failure counts from github_backup_config.
  897. try:
  898. async with async_session() as gb_db:
  899. info["integrations"]["github_backup"] = await _collect_github_backup_info(gb_db)
  900. except Exception:
  901. logger.debug("Failed to collect GitHub backup info", exc_info=True)
  902. # Slicer-API sidecar reachability (#X1C-investigation-style triage)
  903. try:
  904. info["integrations"]["slicer_api"] = await _collect_slicer_api_info()
  905. except Exception:
  906. logger.debug("Failed to collect slicer-API info", exc_info=True)
  907. # Dependencies
  908. try:
  909. dep_packages = [
  910. "fastapi",
  911. "uvicorn",
  912. "pydantic",
  913. "sqlalchemy",
  914. "paho-mqtt",
  915. "psutil",
  916. "httpx",
  917. "aiofiles",
  918. "cryptography",
  919. "opencv-python-headless",
  920. "numpy",
  921. ]
  922. info["dependencies"] = {}
  923. for pkg in dep_packages:
  924. try:
  925. info["dependencies"][pkg] = importlib.metadata.version(pkg)
  926. except importlib.metadata.PackageNotFoundError:
  927. info["dependencies"][pkg] = None
  928. except Exception:
  929. logger.debug("Failed to collect dependency info", exc_info=True)
  930. # Log file info
  931. try:
  932. log_file = settings.log_dir / "bambuddy.log"
  933. if log_file.exists():
  934. size = log_file.stat().st_size
  935. info["log_file"] = {
  936. "size_bytes": size,
  937. "size_formatted": _format_bytes(size),
  938. }
  939. else:
  940. info["log_file"] = {"size_bytes": 0, "size_formatted": "0 B"}
  941. except Exception:
  942. logger.debug("Failed to collect log file info", exc_info=True)
  943. # Network interfaces (subnets with first two octets masked)
  944. try:
  945. interfaces = get_network_interfaces()
  946. info["network"] = {
  947. "interface_count": len(interfaces),
  948. "interfaces": [{"name": iface["name"], "subnet": _mask_subnet(iface["subnet"])} for iface in interfaces],
  949. }
  950. except Exception:
  951. logger.debug("Failed to collect network info", exc_info=True)
  952. # WebSocket connections
  953. try:
  954. info["websockets"] = {
  955. "active_connections": len(ws_manager.active_connections),
  956. }
  957. except Exception:
  958. logger.debug("Failed to collect WebSocket info", exc_info=True)
  959. # Active diagnostics — per-printer connection check, per-VP setup check,
  960. # and the log-health scan. These all surface in the UI today (System page +
  961. # bug-report bubble) but were never persisted into what the maintainer
  962. # receives, so a "looks broken in bambuddy" report arrived with no
  963. # actionable signal beyond raw logs. The snapshot helper is fail-soft per
  964. # probe and bounded by a per-probe wall-clock cap, so a hung interface
  965. # adds at most ~15 s to bundle generation regardless of fleet size (probes
  966. # run concurrently).
  967. try:
  968. from backend.app.services.diagnostic_snapshot import collect_diagnostic_snapshot
  969. async with async_session() as db:
  970. info["diagnostics"] = await collect_diagnostic_snapshot(db)
  971. except Exception:
  972. logger.warning("Failed to collect diagnostic snapshot", exc_info=True)
  973. return info
  974. def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[str, str] | None = None) -> bytes:
  975. """Get log file content, limited to max_bytes from the end."""
  976. log_file = settings.log_dir / "bambuddy.log"
  977. if not log_file.exists():
  978. return b"Log file not found"
  979. file_size = log_file.stat().st_size
  980. if file_size <= max_bytes:
  981. content = log_file.read_text(encoding="utf-8", errors="replace")
  982. else:
  983. # Read last max_bytes
  984. with open(log_file, "rb") as f:
  985. f.seek(file_size - max_bytes)
  986. # Skip partial line at start
  987. f.readline()
  988. content = f.read().decode("utf-8", errors="replace")
  989. # Sanitize sensitive data
  990. content = sanitize_log_content(content, sensitive_strings)
  991. return content.encode("utf-8")
  992. async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
  993. """Get recent log lines, sanitized for inclusion in bug reports."""
  994. # Collect sensitive strings from DB for redaction
  995. async with async_session() as db:
  996. sensitive_strings = await collect_sensitive_strings(db)
  997. log_file = settings.log_dir / "bambuddy.log"
  998. if not log_file.exists():
  999. return ""
  1000. # Read last portion of log file
  1001. try:
  1002. content = log_file.read_text(encoding="utf-8", errors="replace")
  1003. lines = content.splitlines()
  1004. recent = "\n".join(lines[-max_lines:])
  1005. return sanitize_log_content(recent, sensitive_strings)
  1006. except Exception:
  1007. logger.debug("Failed to read logs for bug report", exc_info=True)
  1008. return ""
  1009. @router.get("/bundle")
  1010. async def generate_support_bundle(
  1011. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  1012. ):
  1013. """Generate a support bundle ZIP file for issue reporting."""
  1014. # Check if debug logging is enabled and collect sensitive values for redaction
  1015. async with async_session() as db:
  1016. enabled, _enabled_at = await _get_debug_setting(db)
  1017. if not enabled:
  1018. raise HTTPException(
  1019. status_code=400,
  1020. detail="Debug logging must be enabled before generating a support bundle. "
  1021. "Please enable debug logging, reproduce the issue, then generate the bundle.",
  1022. )
  1023. # Collect known sensitive values for log redaction
  1024. sensitive_strings = await collect_sensitive_strings(db)
  1025. # Collect support info
  1026. support_info = await _collect_support_info()
  1027. # Create ZIP in memory
  1028. zip_buffer = io.BytesIO()
  1029. timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
  1030. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1031. # Add support info JSON
  1032. zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
  1033. # Add log file
  1034. log_content = _get_log_content(sensitive_strings=sensitive_strings)
  1035. zf.writestr("bambuddy.log", log_content)
  1036. zip_buffer.seek(0)
  1037. filename = f"bambuddy-support-{timestamp}.zip"
  1038. logger.info("Generated support bundle: %s", filename)
  1039. return StreamingResponse(
  1040. zip_buffer, media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={filename}"}
  1041. )
  1042. async def init_debug_logging():
  1043. """Initialize debug logging state from database on startup."""
  1044. try:
  1045. async with async_session() as db:
  1046. enabled, _ = await _get_debug_setting(db)
  1047. if enabled:
  1048. _apply_log_level(True)
  1049. logger.info("Debug logging restored from previous session")
  1050. except Exception as e:
  1051. logger.warning("Could not restore debug logging state: %s", e)