mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 18:47:13 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user