Browse Source

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.
maziggy 3 months ago
parent
commit
8449e1c2e2

+ 2 - 0
CHANGELOG.md

@@ -12,6 +12,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **Home Assistant Environment Variables** ([#283](https://github.com/maziggy/bambuddy/issues/283)) — Configure Home Assistant integration via `HA_URL` and `HA_TOKEN` environment variables for zero-configuration add-on deployments. Auto-enables when both variables are set. UI fields become read-only with lock icons when env-managed. Database values preserved as fallback.
 - **Home Assistant Environment Variables** ([#283](https://github.com/maziggy/bambuddy/issues/283)) — Configure Home Assistant integration via `HA_URL` and `HA_TOKEN` environment variables for zero-configuration add-on deployments. Auto-enables when both variables are set. UI fields become read-only with lock icons when env-managed. Database values preserved as fallback.
 - **Spoolman Fill Level for AMS Lite / External Spools** ([#293](https://github.com/maziggy/bambuddy/issues/293)) — AMS Lite (no weight sensor) always reported 0% fill level. Now uses Spoolman's remaining weight as a fallback when AMS reports 0%. External spools also show fill level from Spoolman data. Fill bars and hover cards indicate "(Spoolman)" when the data source is Spoolman rather than AMS.
 - **Spoolman Fill Level for AMS Lite / External Spools** ([#293](https://github.com/maziggy/bambuddy/issues/293)) — AMS Lite (no weight sensor) always reported 0% fill level. Now uses Spoolman's remaining weight as a fallback when AMS reports 0%. External spools also show fill level from Spoolman data. Fill bars and hover cards indicate "(Spoolman)" when the data source is Spoolman rather than AMS.
 
 
+- **Extended Support Bundle Diagnostics** — Support bundle now collects comprehensive diagnostic data for faster issue resolution: printer connectivity and firmware versions, integration status (Spoolman, MQTT, Home Assistant), network interfaces (subnets only), Python package versions, database health checks, Docker environment details, WebSocket connections, and log file info. All data properly anonymized — no IPs, names, or serials included. Privacy disclosure updated on System Info page.
+
 ### Improved
 ### Improved
 - **Auto-Detect Subnet for Printer Discovery** — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.
 - **Auto-Detect Subnet for Printer Discovery** — Docker users no longer need to manually enter a subnet in the Add Printer dialog. Bambuddy auto-detects available network subnets and pre-selects the first one. When multiple subnets are available (e.g., eth0 + wlan0), a dropdown lets users choose. Falls back to manual text input if no subnets are detected.
 - **Japanese Locale Complete Overhaul** — Restructured `ja.ts` from a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.
 - **Japanese Locale Complete Overhaul** — Restructured `ja.ts` from a divergent format (different key structure, 12 structural conflicts, 1,366 missing translations) to match the English/German locale structure exactly. Translated all 2,083 keys into Japanese, achieving full parity with EN/DE. Zero structural divergences, zero missing keys.

+ 1 - 1
README.md

@@ -175,7 +175,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Firmware update helper (LAN-only printers)
 - Firmware update helper (LAN-only printers)
 - Debug logging toggle with live indicator
 - Debug logging toggle with live indicator
 - Live application log viewer with filtering
 - Live application log viewer with filtering
-- Support bundle generator (privacy-filtered)
+- Support bundle generator with comprehensive diagnostics (privacy-filtered)
 
 
 ### 🔒 Optional Authentication
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time
 - Enable/disable authentication any time

+ 258 - 3
backend/app/api/routes/support.py

@@ -1,6 +1,9 @@
 """Support endpoints for debug logging and support bundle generation."""
 """Support endpoints for debug logging and support bundle generation."""
 
 
+import asyncio
+import importlib.metadata
 import io
 import io
+import ipaddress
 import json
 import json
 import logging
 import logging
 import os
 import os
@@ -8,24 +11,30 @@ import platform
 import re
 import re
 import zipfile
 import zipfile
 from datetime import datetime
 from datetime import datetime
+from pathlib import Path
 
 
 from fastapi import APIRouter, HTTPException, Query
 from fastapi import APIRouter, HTTPException, Query
 from fastapi.responses import StreamingResponse
 from fastapi.responses import StreamingResponse
 from pydantic import BaseModel
 from pydantic import BaseModel
-from sqlalchemy import func, select
+from sqlalchemy import func, select, text
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import async_session
 from backend.app.core.database import async_session
 from backend.app.core.permissions import Permission
 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.archive import PrintArchive
 from backend.app.models.filament import Filament
 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.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.user import User
 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"])
 router = APIRouter(prefix="/support", tags=["support"])
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -313,8 +322,71 @@ def _sanitize_path(path: str) -> str:
     return path
     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:
 async def _collect_support_info() -> dict:
     """Collect all support information."""
     """Collect all support information."""
+    in_docker = is_running_in_docker()
+
     info = {
     info = {
         "generated_at": datetime.now().isoformat(),
         "generated_at": datetime.now().isoformat(),
         "app": {
         "app": {
@@ -329,15 +401,29 @@ async def _collect_support_info() -> dict:
             "python_version": platform.python_version(),
             "python_version": platform.python_version(),
         },
         },
         "environment": {
         "environment": {
-            "docker": os.path.exists("/.dockerenv"),
+            "docker": in_docker,
             "data_dir": _sanitize_path(str(settings.base_dir)),
             "data_dir": _sanitize_path(str(settings.base_dir)),
             "log_dir": _sanitize_path(str(settings.log_dir)),
             "log_dir": _sanitize_path(str(settings.log_dir)),
+            "timezone": os.environ.get("TZ", ""),
         },
         },
         "database": {},
         "database": {},
         "printers": [],
         "printers": [],
         "settings": {},
         "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:
     async with async_session() as db:
         # Database stats
         # Database stats
         result = await db.execute(select(func.count(PrintArchive.id)))
         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)))
         result = await db.execute(select(func.count(SmartPlug.id)))
         info["database"]["smart_plugs_total"] = result.scalar() or 0
         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))
         result = await db.execute(select(Printer))
         printers = result.scalars().all()
         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):
         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(
             info["printers"].append(
                 {
                 {
                     "index": i + 1,
                     "index": i + 1,
                     "model": printer.model or "Unknown",
                     "model": printer.model or "Unknown",
                     "nozzle_count": printer.nozzle_count,
                     "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
                 continue
             info["settings"][s.key] = s.value
             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
     return info
 
 
 
 

+ 428 - 0
backend/tests/unit/test_support_helpers.py

@@ -0,0 +1,428 @@
+"""Unit tests for support module helper functions.
+
+Tests _anonymize_mqtt_broker, _check_port, _get_container_memory_limit,
+_format_bytes, and _collect_support_info diagnostic sections.
+"""
+
+import asyncio
+import tempfile
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+class TestAnonymizeMqttBroker:
+    """Tests for _anonymize_mqtt_broker()."""
+
+    def test_empty_string(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("") == ""
+
+    def test_ipv4_address(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("192.168.1.100") == "[IP]"
+
+    def test_ipv6_address(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("::1") == "[IP]"
+
+    def test_hostname_with_domain(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("mqtt.example.com") == "*.example.com"
+
+    def test_hostname_with_subdomain(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("broker.mqtt.example.com") == "*.example.com"
+
+    def test_single_part_hostname(self):
+        from backend.app.api.routes.support import _anonymize_mqtt_broker
+
+        assert _anonymize_mqtt_broker("localhost") == "localhost"
+
+
+class TestCheckPort:
+    """Tests for _check_port()."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_reachable_port(self):
+        from backend.app.api.routes.support import _check_port
+
+        # Mock a successful connection
+        mock_writer = AsyncMock()
+        mock_writer.close = MagicMock()
+        mock_writer.wait_closed = AsyncMock()
+
+        with patch("backend.app.api.routes.support.asyncio.open_connection", return_value=(AsyncMock(), mock_writer)):
+            result = await _check_port("192.168.1.1", 8883, timeout=1.0)
+
+        assert result is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_unreachable_port(self):
+        from backend.app.api.routes.support import _check_port
+
+        with (
+            patch(
+                "backend.app.api.routes.support.asyncio.open_connection",
+                side_effect=ConnectionRefusedError,
+            ),
+            patch(
+                "backend.app.api.routes.support.asyncio.wait_for",
+                side_effect=ConnectionRefusedError,
+            ),
+        ):
+            result = await _check_port("192.168.1.1", 8883, timeout=1.0)
+
+        assert result is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_timeout(self):
+        from backend.app.api.routes.support import _check_port
+
+        with patch(
+            "backend.app.api.routes.support.asyncio.wait_for",
+            side_effect=asyncio.TimeoutError,
+        ):
+            result = await _check_port("192.168.1.1", 8883, timeout=0.1)
+
+        assert result is False
+
+
+class TestGetContainerMemoryLimit:
+    """Tests for _get_container_memory_limit()."""
+
+    def test_cgroup_v2_with_limit(self):
+        from backend.app.api.routes.support import _get_container_memory_limit
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            v2_path = Path(tmpdir) / "memory.max"
+            v2_path.write_text("1073741824\n")
+
+            with patch("backend.app.api.routes.support.Path") as mock_path:
+                # v2 path exists with value
+                v2_mock = MagicMock()
+                v2_mock.exists.return_value = True
+                v2_mock.read_text.return_value = "1073741824\n"
+
+                v1_mock = MagicMock()
+                v1_mock.exists.return_value = False
+
+                mock_path.side_effect = lambda p: v2_mock if "memory.max" in p else v1_mock
+
+                result = _get_container_memory_limit()
+
+        assert result == 1073741824
+
+    def test_cgroup_v2_unlimited(self):
+        from backend.app.api.routes.support import _get_container_memory_limit
+
+        with patch("backend.app.api.routes.support.Path") as mock_path:
+            v2_mock = MagicMock()
+            v2_mock.exists.return_value = True
+            v2_mock.read_text.return_value = "max\n"
+
+            v1_mock = MagicMock()
+            v1_mock.exists.return_value = False
+
+            mock_path.side_effect = lambda p: v2_mock if "memory.max" in p else v1_mock
+
+            result = _get_container_memory_limit()
+
+        assert result is None
+
+    def test_no_cgroup_files(self):
+        from backend.app.api.routes.support import _get_container_memory_limit
+
+        with patch("backend.app.api.routes.support.Path") as mock_path:
+            mock_instance = MagicMock()
+            mock_instance.exists.return_value = False
+            mock_path.return_value = mock_instance
+
+            result = _get_container_memory_limit()
+
+        assert result is None
+
+
+class TestFormatBytes:
+    """Tests for _format_bytes()."""
+
+    def test_bytes(self):
+        from backend.app.api.routes.support import _format_bytes
+
+        assert _format_bytes(500) == "500 B"
+
+    def test_kilobytes(self):
+        from backend.app.api.routes.support import _format_bytes
+
+        assert _format_bytes(2048) == "2.0 KB"
+
+    def test_megabytes(self):
+        from backend.app.api.routes.support import _format_bytes
+
+        assert _format_bytes(10 * 1024 * 1024) == "10.0 MB"
+
+    def test_gigabytes(self):
+        from backend.app.api.routes.support import _format_bytes
+
+        assert _format_bytes(2 * 1024 * 1024 * 1024) == "2.00 GB"
+
+    def test_zero(self):
+        from backend.app.api.routes.support import _format_bytes
+
+        assert _format_bytes(0) == "0 B"
+
+
+class TestCollectSupportInfo:
+    """Tests for _collect_support_info() new diagnostic sections."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_environment_has_timezone(self):
+        """Verify environment section includes timezone."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+            patch.dict("os.environ", {"TZ": "America/New_York"}),
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = []
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert info["environment"]["timezone"] == "America/New_York"
+        assert info["environment"]["docker"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_docker_section_present_when_in_docker(self):
+        """Verify docker section is added when running in Docker."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=True),
+            patch("backend.app.api.routes.support._get_container_memory_limit", return_value=1073741824),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch(
+                "backend.app.api.routes.support.get_network_interfaces",
+                return_value=[{"name": "eth0", "subnet": "172.17.0.0/16"}],
+            ),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = []
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert "docker" in info
+        assert info["docker"]["container_memory_limit_bytes"] == 1073741824
+        assert info["docker"]["container_memory_limit_formatted"] == "1.00 GB"
+        assert info["docker"]["network_mode_hint"] == "bridge"
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_docker_section_absent_when_not_docker(self):
+        """Verify docker section is absent when not in Docker."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = []
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert "docker" not in info
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_dependencies_section(self):
+        """Verify dependencies section lists package versions."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = []
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert "dependencies" in info
+        # fastapi should be installed in test environment
+        assert "fastapi" in info["dependencies"]
+        assert info["dependencies"]["fastapi"] is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_websockets_section(self):
+        """Verify websockets section shows connection count."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = ["conn1", "conn2"]
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert info["websockets"]["active_connections"] == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_network_section(self):
+        """Verify network section shows interface subnets."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        mock_interfaces = [
+            {"name": "eth0", "ip": "192.168.1.100", "netmask": "255.255.255.0", "subnet": "192.168.1.0/24"},
+            {"name": "wlan0", "ip": "10.0.0.50", "netmask": "255.255.255.0", "subnet": "10.0.0.0/24"},
+        ]
+
+        with (
+            patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+            patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+            patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.support.get_network_interfaces", return_value=mock_interfaces),
+            patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+        ):
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = []
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalar.return_value = 0
+            mock_result.scalar_one_or_none.return_value = None
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+            info = await _collect_support_info()
+
+        assert info["network"]["interface_count"] == 2
+        assert info["network"]["interfaces"][0]["name"] == "eth0"
+        assert info["network"]["interfaces"][0]["subnet"] == "192.168.1.0/24"
+        # Verify IP addresses are NOT included
+        for iface in info["network"]["interfaces"]:
+            assert "ip" not in iface
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_log_file_section(self):
+        """Verify log file section shows size info."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_dir = Path(tmpdir)
+            log_file = log_dir / "bambuddy.log"
+            log_file.write_text("some log content\n" * 100)
+
+            with (
+                patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
+                patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
+                patch("backend.app.api.routes.support.printer_manager") as mock_pm,
+                patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
+                patch("backend.app.api.routes.support.ws_manager") as mock_ws,
+                patch("backend.app.api.routes.support.settings") as mock_settings,
+            ):
+                mock_settings.base_dir = Path(tmpdir)
+                mock_settings.log_dir = log_dir
+                mock_settings.debug = False
+                mock_pm.get_all_statuses.return_value = {}
+                mock_ws.active_connections = []
+
+                mock_db = AsyncMock()
+                mock_result = MagicMock()
+                mock_result.scalar.return_value = 0
+                mock_result.scalar_one_or_none.return_value = None
+                mock_result.scalars.return_value.all.return_value = []
+                mock_db.execute = AsyncMock(return_value=mock_result)
+
+                mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+                mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
+
+                info = await _collect_support_info()
+
+        assert "log_file" in info
+        assert info["log_file"]["size_bytes"] > 0
+        assert "B" in info["log_file"]["size_formatted"] or "KB" in info["log_file"]["size_formatted"]

+ 22 - 0
frontend/src/__tests__/pages/SystemInfoPage.test.tsx

@@ -281,6 +281,28 @@ describe('SystemInfoPage', () => {
     expect(progressBars.length).toBeGreaterThan(0);
     expect(progressBars.length).toBeGreaterThan(0);
   });
   });
 
 
+  it('displays extended privacy disclosure items', async () => {
+    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
+
+    render(<SystemInfoPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText("What's in the support bundle?")).toBeInTheDocument();
+    });
+
+    // Original items
+    expect(screen.getByText(/App version and debug mode/)).toBeInTheDocument();
+    expect(screen.getByText(/Debug logs \(sanitized\)/)).toBeInTheDocument();
+
+    // New diagnostic items
+    expect(screen.getByText(/Printer connectivity and firmware versions/)).toBeInTheDocument();
+    expect(screen.getByText(/Integration status \(Spoolman, MQTT, HA\)/)).toBeInTheDocument();
+    expect(screen.getByText(/Network interfaces \(subnets only\)/)).toBeInTheDocument();
+    expect(screen.getByText(/Python package versions/)).toBeInTheDocument();
+    expect(screen.getByText(/Database health checks/)).toBeInTheDocument();
+    expect(screen.getByText(/Docker environment details/)).toBeInTheDocument();
+  });
+
   it('applies danger color for critical disk usage', async () => {
   it('applies danger color for critical disk usage', async () => {
     const criticalDiskUsage = {
     const criticalDiskUsage = {
       ...mockSystemInfo,
       ...mockSystemInfo,

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -1809,6 +1809,12 @@ export default {
   support: {
   support: {
     debugLoggingActive: 'Debug-Protokollierung ist aktiv',
     debugLoggingActive: 'Debug-Protokollierung ist aktiv',
     manageLogs: 'Verwalten',
     manageLogs: 'Verwalten',
+    collectItem7: 'Drucker-Verbindungsstatus und Firmware-Versionen',
+    collectItem8: 'Integrationsstatus (Spoolman, MQTT, HA)',
+    collectItem9: 'Netzwerkschnittstellen (nur Subnetze)',
+    collectItem10: 'Python-Paketversionen',
+    collectItem11: 'Datenbankzustandsprüfungen',
+    collectItem12: 'Docker-Umgebungsdetails',
   },
   },
 
 
   // File manager
   // File manager

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -1809,6 +1809,12 @@ export default {
   support: {
   support: {
     debugLoggingActive: 'Debug logging is active',
     debugLoggingActive: 'Debug logging is active',
     manageLogs: 'Manage',
     manageLogs: 'Manage',
+    collectItem7: 'Printer connectivity and firmware versions',
+    collectItem8: 'Integration status (Spoolman, MQTT, HA)',
+    collectItem9: 'Network interfaces (subnets only)',
+    collectItem10: 'Python package versions',
+    collectItem11: 'Database health checks',
+    collectItem12: 'Docker environment details',
   },
   },
 
 
   // File manager
   // File manager

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -2526,5 +2526,11 @@ export default {
   support: {
   support: {
     debugLoggingActive: 'デバッグログが有効です',
     debugLoggingActive: 'デバッグログが有効です',
     manageLogs: '管理',
     manageLogs: '管理',
+    collectItem7: 'プリンター接続状態とファームウェアバージョン',
+    collectItem8: '連携状態(Spoolman、MQTT、HA)',
+    collectItem9: 'ネットワークインターフェース(サブネットのみ)',
+    collectItem10: 'Pythonパッケージバージョン',
+    collectItem11: 'データベース健全性チェック',
+    collectItem12: 'Docker環境の詳細',
   },
   },
 };
 };

+ 6 - 0
frontend/src/pages/SystemInfoPage.tsx

@@ -324,6 +324,12 @@ export function SystemInfoPage() {
                   <li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
                   <li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
                   <li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
                   <li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
                   <li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
                   <li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
+                  <li>• {t('support.collectItem7', 'Printer connectivity and firmware versions')}</li>
+                  <li>• {t('support.collectItem8', 'Integration status (Spoolman, MQTT, HA)')}</li>
+                  <li>• {t('support.collectItem9', 'Network interfaces (subnets only)')}</li>
+                  <li>• {t('support.collectItem10', 'Python package versions')}</li>
+                  <li>• {t('support.collectItem11', 'Database health checks')}</li>
+                  <li>• {t('support.collectItem12', 'Docker environment details')}</li>
                 </ul>
                 </ul>
               </div>
               </div>
               <div>
               <div>

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CBPKqOAD.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Brr6L6Pf.js"></script>
+    <script type="module" crossorigin src="/assets/index-CBPKqOAD.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BTJM8cN7.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BTJM8cN7.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff