Jelajahi Sumber

feat(support): include all settings (redacted) + SpoolBuddy devices in support bundle

  Settings dump now retains every key from the Settings table and replaces
  sensitive values with [REDACTED] instead of dropping the row. New config
  flags automatically surface in future bundles without a code change.

  Adds integrations.spoolbuddy with per-device firmware, NFC/scale hardware,
  calibration, online state and uptime — anonymized (no hostnames, IPs or
  device IDs). Both /support/bundle and the bug-report bubble benefit, since
  they share _collect_support_info().
maziggy 1 bulan lalu
induk
melakukan
b5ccc38e4a
3 mengubah file dengan 109 tambahan dan 5 penghapusan
  1. 1 0
      CHANGELOG.md
  2. 50 5
      backend/app/api/routes/support.py
  3. 58 0
      backend/tests/unit/test_support_helpers.py

+ 1 - 0
CHANGELOG.md

@@ -17,6 +17,7 @@ All notable changes to Bambuddy will be documented in this file.
 ### Improved
 ### Improved
 - **Firmware Update Modal Shows All Announced Versions** ([#568](https://github.com/maziggy/bambuddy/issues/568)) — The firmware update dialog now lists every version announced on Bambu Lab's wiki release history, not just the single newest one. Each row shows whether an offline firmware file is actually available for that version — rows marked **Usable** (green) can be installed, rows marked **Unavailable** (gray) are announced but have no downloadable package yet (common for hot-fix releases like `01.01.03.00` which Bambu only ships as OTA). The currently installed version is highlighted with a blue **Installed** badge. Selecting any usable row swaps the release-notes block at the top to that version's notes and enables the Install button for it — including older-than-current versions, so you can roll back to a previous firmware without having to hand-flash a file. The wiki scraper was tightened to only extract version numbers from heading anchors (e.g. `id="h-01030000-20260303"`) so incidental version mentions in release-note prose — like an AMS firmware reference in an H2D changelog — no longer get mistaken for H2D firmware releases. Thanks to @Cornelicorn for the request.
 - **Firmware Update Modal Shows All Announced Versions** ([#568](https://github.com/maziggy/bambuddy/issues/568)) — The firmware update dialog now lists every version announced on Bambu Lab's wiki release history, not just the single newest one. Each row shows whether an offline firmware file is actually available for that version — rows marked **Usable** (green) can be installed, rows marked **Unavailable** (gray) are announced but have no downloadable package yet (common for hot-fix releases like `01.01.03.00` which Bambu only ships as OTA). The currently installed version is highlighted with a blue **Installed** badge. Selecting any usable row swaps the release-notes block at the top to that version's notes and enables the Install button for it — including older-than-current versions, so you can roll back to a previous firmware without having to hand-flash a file. The wiki scraper was tightened to only extract version numbers from heading anchors (e.g. `id="h-01030000-20260303"`) so incidental version mentions in release-note prose — like an AMS firmware reference in an H2D changelog — no longer get mistaken for H2D firmware releases. Thanks to @Cornelicorn for the request.
 - **Spoolbuddy Device Controls in Settings** ([#962](https://github.com/maziggy/bambuddy/issues/962)) — Each Spoolbuddy device card in Settings → Spoolbuddy now exposes five one-click actions alongside the existing Unregister button: Update (trigger daemon software update), Restart Browser (kiosk UI), Restart Daemon, Reboot (device), and Shutdown. Each action shows a confirmation dialog before queueing the command; buttons are disabled when the device is offline. Uses the existing `/spoolbuddy/devices/{id}/update` and `/spoolbuddy/devices/{id}/system/command` endpoints — no new backend work needed. Thanks to @TravisWilder for the request.
 - **Spoolbuddy Device Controls in Settings** ([#962](https://github.com/maziggy/bambuddy/issues/962)) — Each Spoolbuddy device card in Settings → Spoolbuddy now exposes five one-click actions alongside the existing Unregister button: Update (trigger daemon software update), Restart Browser (kiosk UI), Restart Daemon, Reboot (device), and Shutdown. Each action shows a confirmation dialog before queueing the command; buttons are disabled when the device is offline. Uses the existing `/spoolbuddy/devices/{id}/update` and `/spoolbuddy/devices/{id}/system/command` endpoints — no new backend work needed. Thanks to @TravisWilder for the request.
+- **Support Bundle Covers All Settings & SpoolBuddy** — The support bundle / bug-report payload now dumps every row in the `Settings` table instead of filtering by a hard-coded allowlist: sensitive keys (tokens, passwords, URLs, paths, emails, etc.) have their values replaced with `[REDACTED]` but the key itself is kept, so new config flags automatically show up in future bundles without a code change. Also adds an `integrations.spoolbuddy` section listing registered SpoolBuddy devices (firmware version, NFC/scale hardware, calibration, online state, uptime) — anonymized, no hostnames/IPs/device IDs.
 - **Settings Search Finds More Cards** — The cross-tab search field at the top of Settings now finds Sidebar Links, Spoolman, Spool Catalog, Color Catalog, all four Failure Detection sections, Advanced Email Authentication, SMTP Test, Authenticator App (TOTP), Email OTP, 2FA Linked Accounts, Single Sign-On (OIDC), LDAP Server Configuration, and the four Backup sub-cards (GitHub, History, Local, Scheduled). Powered by a new module-level registry (`frontend/src/lib/settingsSearch.ts`) so future settings register themselves next to their component instead of being forgotten in a central array.
 - **Settings Search Finds More Cards** — The cross-tab search field at the top of Settings now finds Sidebar Links, Spoolman, Spool Catalog, Color Catalog, all four Failure Detection sections, Advanced Email Authentication, SMTP Test, Authenticator App (TOTP), Email OTP, 2FA Linked Accounts, Single Sign-On (OIDC), LDAP Server Configuration, and the four Backup sub-cards (GitHub, History, Local, Scheduled). Powered by a new module-level registry (`frontend/src/lib/settingsSearch.ts`) so future settings register themselves next to their component instead of being forgotten in a central array.
 
 
 ### Changed
 ### Changed

+ 50 - 5
backend/app/api/routes/support.py

@@ -556,7 +556,10 @@ async def _collect_support_info() -> dict:
         except Exception:
         except Exception:
             logger.debug("Failed to collect virtual printer info", exc_info=True)
             logger.debug("Failed to collect virtual printer info", exc_info=True)
 
 
-        # Non-sensitive settings
+        # All settings — sensitive values are redacted rather than dropped so
+        # new settings automatically show up in support bundles without a code
+        # change. The value is replaced with "[REDACTED]" but the key is kept
+        # so we can still see which integrations are configured.
         result = await db.execute(select(Settings))
         result = await db.execute(select(Settings))
         all_settings = result.scalars().all()
         all_settings = result.scalars().all()
         sensitive_keys = {
         sensitive_keys = {
@@ -578,12 +581,16 @@ async def _collect_support_info() -> dict:
             "path",  # Filesystem paths may contain usernames
             "path",  # Filesystem paths may contain usernames
             "config",  # URLs may contain IPs, configs may have embedded secrets
             "config",  # URLs may contain IPs, configs may have embedded secrets
             "_ip",  # IP address fields (e.g. virtual_printer_remote_interface_ip)
             "_ip",  # IP address fields (e.g. virtual_printer_remote_interface_ip)
+            "host",
+            "credential",
         }
         }
         for s in all_settings:
         for s in all_settings:
-            # Skip sensitive settings
-            if any(sensitive in s.key.lower() for sensitive in sensitive_keys):
-                continue
-            info["settings"][s.key] = s.value
+            key_lower = s.key.lower()
+            if any(sensitive in key_lower for sensitive in sensitive_keys):
+                # Preserve shape: mark presence without leaking the value
+                info["settings"][s.key] = "[REDACTED]" if s.value else ""
+            else:
+                info["settings"][s.key] = s.value
 
 
         # Notification providers (anonymized — type/enabled/error status only)
         # Notification providers (anonymized — type/enabled/error status only)
         try:
         try:
@@ -668,6 +675,44 @@ async def _collect_support_info() -> dict:
     except Exception:
     except Exception:
         logger.debug("Failed to collect MQTT relay info", exc_info=True)
         logger.debug("Failed to collect MQTT relay info", exc_info=True)
 
 
+    # SpoolBuddy devices (anonymized — no hostnames, IPs or device IDs)
+    try:
+        async with async_session() as db:
+            from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+
+            result = await db.execute(select(SpoolBuddyDevice))
+            devices = result.scalars().all()
+            info["integrations"]["spoolbuddy"] = {
+                "device_count": len(devices),
+                "online_count": sum(
+                    1
+                    for d in devices
+                    if d.last_seen
+                    and (datetime.now(tz=timezone.utc) - d.last_seen.replace(tzinfo=timezone.utc)).total_seconds() < 30
+                ),
+                "devices": [
+                    {
+                        "index": i + 1,
+                        "firmware_version": d.firmware_version,
+                        "has_nfc": d.has_nfc,
+                        "has_scale": d.has_scale,
+                        "nfc_reader_type": d.nfc_reader_type,
+                        "nfc_connection": d.nfc_connection,
+                        "has_backlight": d.has_backlight,
+                        "nfc_ok": d.nfc_ok,
+                        "scale_ok": d.scale_ok,
+                        "uptime_s": d.uptime_s,
+                        "calibration_factor": d.calibration_factor,
+                        "tare_offset": d.tare_offset,
+                        "last_calibrated_at": d.last_calibrated_at.isoformat() if d.last_calibrated_at else None,
+                        "update_status": d.update_status,
+                    }
+                    for i, d in enumerate(devices)
+                ],
+            }
+    except Exception:
+        logger.debug("Failed to collect SpoolBuddy info", exc_info=True)
+
     # Home Assistant (check ha_enabled setting)
     # Home Assistant (check ha_enabled setting)
     try:
     try:
         info["integrations"]["homeassistant"] = {
         info["integrations"]["homeassistant"] = {

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

@@ -578,3 +578,61 @@ class TestCollectSupportInfo:
         assert "log_file" in info
         assert "log_file" in info
         assert info["log_file"]["size_bytes"] > 0
         assert info["log_file"]["size_bytes"] > 0
         assert "B" in info["log_file"]["size_formatted"] or "KB" in info["log_file"]["size_formatted"]
         assert "B" in info["log_file"]["size_formatted"] or "KB" in info["log_file"]["size_formatted"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.unit
+    async def test_settings_include_all_keys_with_sensitive_redacted(self):
+        """All settings keys must appear in output; sensitive values are replaced with [REDACTED]."""
+        from backend.app.api.routes.support import _collect_support_info
+
+        fake_settings = [
+            MagicMock(key="benign_flag", value="true"),
+            MagicMock(key="bambu_cloud_token", value="super-secret"),
+            MagicMock(key="github_webhook", value="https://hooks.example/abc"),
+            MagicMock(key="empty_password", value=""),
+            MagicMock(key="local_backup_path", value="/data/backups"),
+        ]
+
+        def make_result(rows=None):
+            r = MagicMock()
+            r.scalar.return_value = 0
+            r.scalar_one_or_none.return_value = None
+            r.scalars.return_value.all.return_value = rows or []
+            r.all.return_value = []
+            return r
+
+        async def fake_execute(stmt, *_a, **_kw):
+            sql = str(stmt).lower()
+            # Route by table name in the compiled SQL
+            if "from settings" in sql or "settings.key" in sql:
+                return make_result(fake_settings)
+            return make_result([])
+
+        with (
+            tempfile.TemporaryDirectory() as tmpdir,
+            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 = Path(tmpdir)
+            mock_settings.debug = False
+            mock_pm.get_all_statuses.return_value = {}
+            mock_ws.active_connections = []
+
+            mock_db = AsyncMock()
+            mock_db.execute = fake_execute
+            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()
+
+        s = info["settings"]
+        assert s.get("bambu_cloud_token") == "[REDACTED]"
+        assert s.get("github_webhook") == "[REDACTED]"
+        assert s.get("local_backup_path") == "[REDACTED]"
+        assert s.get("empty_password") == ""
+        assert s.get("benign_flag") == "true"