Extend support bundle with comprehensive diagnostics

Add 10 new diagnostic sections to _collect_support_info(): printer
connectivity/firmware, integration status (Spoolman, MQTT, HA),
network interfaces (subnets only), Python package versions, database
health, Docker environment, WebSocket connections, and log file info.
All data properly anonymized — no IPs, names, or serials included.
This commit is contained in:
maziggy
2026-02-09 10:19:48 +01:00
parent 48174d3a08
commit 8449e1c2e2
11 changed files with 738 additions and 7 deletions
+258 -3
View File
@@ -1,6 +1,9 @@
"""Support endpoints for debug logging and support bundle generation."""
import asyncio
import importlib.metadata
import io
import ipaddress
import json
import logging
import os
@@ -8,24 +11,30 @@ import platform
import re
import zipfile
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.config import APP_VERSION, settings
from backend.app.core.database import async_session
from backend.app.core.permissions import Permission
from backend.app.core.websocket import ws_manager
from backend.app.models.archive import PrintArchive
from backend.app.models.filament import Filament
from backend.app.models.notification import NotificationProvider
from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.user import User
from backend.app.services.discovery import is_running_in_docker
from backend.app.services.network_utils import get_network_interfaces
from backend.app.services.printer_manager import printer_manager
router = APIRouter(prefix="/support", tags=["support"])
logger = logging.getLogger(__name__)
@@ -313,8 +322,71 @@ def _sanitize_path(path: str) -> str:
return path
def _anonymize_mqtt_broker(broker: str) -> str:
"""Anonymize MQTT broker address. IPs become [IP], hostnames become *.domain."""
if not broker:
return ""
try:
ipaddress.ip_address(broker)
return "[IP]"
except ValueError:
# It's a hostname — show *.domain pattern
parts = broker.split(".")
if len(parts) >= 2:
return "*." + ".".join(parts[-2:])
return broker
async def _check_port(ip: str, port: int, timeout: float = 2.0) -> bool:
"""Test TCP connectivity to ip:port. Returns True if reachable."""
try:
_reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
writer.close()
await writer.wait_closed()
return True
except Exception:
return False
def _get_container_memory_limit() -> int | None:
"""Read cgroup memory limit. Returns bytes or None."""
# cgroup v2
v2 = Path("/sys/fs/cgroup/memory.max")
if v2.exists():
try:
val = v2.read_text().strip()
if val != "max":
return int(val)
except Exception:
pass
# cgroup v1
v1 = Path("/sys/fs/cgroup/memory/memory.limit_in_bytes")
if v1.exists():
try:
val = int(v1.read_text().strip())
# Values near page-aligned max (2^63-4096) mean unlimited
if val < 2**62:
return val
except Exception:
pass
return None
def _format_bytes(size_bytes: int) -> str:
"""Format bytes into human-readable string."""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
if size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
async def _collect_support_info() -> dict:
"""Collect all support information."""
in_docker = is_running_in_docker()
info = {
"generated_at": datetime.now().isoformat(),
"app": {
@@ -329,15 +401,29 @@ async def _collect_support_info() -> dict:
"python_version": platform.python_version(),
},
"environment": {
"docker": os.path.exists("/.dockerenv"),
"docker": in_docker,
"data_dir": _sanitize_path(str(settings.base_dir)),
"log_dir": _sanitize_path(str(settings.log_dir)),
"timezone": os.environ.get("TZ", ""),
},
"database": {},
"printers": [],
"settings": {},
}
# Docker-specific info
if in_docker:
try:
mem_limit = _get_container_memory_limit()
interfaces = get_network_interfaces()
info["docker"] = {
"container_memory_limit_bytes": mem_limit,
"container_memory_limit_formatted": _format_bytes(mem_limit) if mem_limit else None,
"network_mode_hint": "host" if len(interfaces) > 2 else "bridge",
}
except Exception:
logger.debug("Failed to collect Docker info", exc_info=True)
async with async_session() as db:
# Database stats
result = await db.execute(select(func.count(PrintArchive.id)))
@@ -358,15 +444,52 @@ async def _collect_support_info() -> dict:
result = await db.execute(select(func.count(SmartPlug.id)))
info["database"]["smart_plugs_total"] = result.scalar() or 0
# Printer info (anonymized - just models and connection status)
# Printer info (anonymized - no names, IPs, or serials)
result = await db.execute(select(Printer))
printers = result.scalars().all()
statuses = printer_manager.get_all_statuses()
# Check reachability in parallel
reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
reachable_results = await asyncio.gather(*reachability_tasks, return_exceptions=True)
for i, printer in enumerate(printers):
state = statuses.get(printer.id)
reachable = reachable_results[i] if not isinstance(reachable_results[i], Exception) else False
# Count AMS units and trays from raw_data
ams_unit_count = 0
ams_tray_count = 0
has_vt_tray = False
if state:
ams_data = state.raw_data.get("ams")
if isinstance(ams_data, dict) and "ams" in ams_data:
ams_units = ams_data["ams"]
if isinstance(ams_units, list):
ams_unit_count = len(ams_units)
for unit in ams_units:
trays = unit.get("tray", [])
ams_tray_count += len([t for t in trays if t.get("tray_type")])
has_vt_tray = state.raw_data.get("vt_tray") is not None
info["printers"].append(
{
"index": i + 1,
"model": printer.model or "Unknown",
"nozzle_count": printer.nozzle_count,
"is_active": printer.is_active,
"mqtt_connected": state.connected if state else False,
"state": state.state if state else "unknown",
"firmware_version": state.firmware_version if state else None,
"wifi_signal": state.wifi_signal if state else None,
"reachable": bool(reachable),
"ams_unit_count": ams_unit_count,
"ams_tray_count": ams_tray_count,
"has_vt_tray": has_vt_tray,
"external_camera_configured": bool(printer.external_camera_url),
"plate_detection_enabled": printer.plate_detection_enabled,
"hms_error_count": len(state.hms_errors) if state else 0,
"nozzle_rack_count": len(state.nozzle_rack) if state else 0,
}
)
@@ -396,6 +519,138 @@ async def _collect_support_info() -> dict:
continue
info["settings"][s.key] = s.value
# Notification providers (anonymized — type/enabled/error status only)
try:
result = await db.execute(select(NotificationProvider))
providers = result.scalars().all()
info["integrations"] = info.get("integrations", {})
info["integrations"]["notification_providers"] = [
{
"type": p.provider_type,
"enabled": p.enabled,
"has_last_error": bool(p.last_error),
}
for p in providers
]
except Exception:
logger.debug("Failed to collect notification provider info", exc_info=True)
# Database health
try:
result = await db.execute(text("PRAGMA journal_mode"))
journal_mode = result.scalar()
result = await db.execute(text("PRAGMA quick_check"))
quick_check = result.scalar()
db_path = settings.base_dir / "bambuddy.db"
db_size = db_path.stat().st_size if db_path.exists() else 0
wal_path = settings.base_dir / "bambuddy.db-wal"
wal_size = wal_path.stat().st_size if wal_path.exists() else 0
info["database_health"] = {
"journal_mode": journal_mode,
"quick_check": quick_check,
"db_size_bytes": db_size,
"wal_size_bytes": wal_size,
}
except Exception:
logger.debug("Failed to collect database health info", exc_info=True)
# Integrations (lazy imports to avoid circular dependencies)
info.setdefault("integrations", {})
# Spoolman
try:
from backend.app.services.spoolman import get_spoolman_client
client = await get_spoolman_client()
if client:
reachable = await client.health_check()
info["integrations"]["spoolman"] = {"enabled": True, "reachable": reachable}
else:
info["integrations"]["spoolman"] = {"enabled": False, "reachable": False}
except Exception:
logger.debug("Failed to collect Spoolman info", exc_info=True)
# MQTT relay
try:
from backend.app.services.mqtt_relay import mqtt_relay
status = mqtt_relay.get_status()
info["integrations"]["mqtt_relay"] = {
"enabled": status.get("enabled", False),
"connected": status.get("connected", False),
"broker": _anonymize_mqtt_broker(status.get("broker", "")),
"port": status.get("port", 0),
"topic_prefix": status.get("topic_prefix", ""),
}
except Exception:
logger.debug("Failed to collect MQTT relay info", exc_info=True)
# Home Assistant (check ha_enabled setting)
try:
info["integrations"]["homeassistant"] = {
"enabled": info["settings"].get("ha_enabled", "false").lower() == "true",
}
except Exception:
logger.debug("Failed to collect Home Assistant info", exc_info=True)
# Dependencies
try:
dep_packages = [
"fastapi",
"uvicorn",
"pydantic",
"sqlalchemy",
"paho-mqtt",
"psutil",
"httpx",
"aiofiles",
"cryptography",
"opencv-python-headless",
"numpy",
]
info["dependencies"] = {}
for pkg in dep_packages:
try:
info["dependencies"][pkg] = importlib.metadata.version(pkg)
except importlib.metadata.PackageNotFoundError:
info["dependencies"][pkg] = None
except Exception:
logger.debug("Failed to collect dependency info", exc_info=True)
# Log file info
try:
log_file = settings.log_dir / "bambuddy.log"
if log_file.exists():
size = log_file.stat().st_size
info["log_file"] = {
"size_bytes": size,
"size_formatted": _format_bytes(size),
}
else:
info["log_file"] = {"size_bytes": 0, "size_formatted": "0 B"}
except Exception:
logger.debug("Failed to collect log file info", exc_info=True)
# Network interfaces (subnets only — already anonymized)
try:
interfaces = get_network_interfaces()
info["network"] = {
"interface_count": len(interfaces),
"interfaces": [{"name": iface["name"], "subnet": iface["subnet"]} for iface in interfaces],
}
except Exception:
logger.debug("Failed to collect network info", exc_info=True)
# WebSocket connections
try:
info["websockets"] = {
"active_connections": len(ws_manager.active_connections),
}
except Exception:
logger.debug("Failed to collect WebSocket info", exc_info=True)
return info