Преглед на файлове

● feat(support): include sanitized connection / VP / log-health diagnostics in support bundle and bug report (#1506 follow-up)

  The three diagnostic surfaces shipped earlier this month
  (6bc6a1d6 VP setup diagnostic, e222a0ef log-health scanner,
  ed31b8f4 connection diagnostic in the bug-report bubble) were
  only ever shown to the *user*. A bug report arriving in the
  maintainer's inbox carried raw logs but no diagnostic results —
  the user-visible "your X1C can't reach MQTT" finding never made
  it into the issue body, so the maintainer had to ask the user
  to re-run and paste.

  New `services/diagnostic_snapshot.collect_diagnostic_snapshot`
  runs all three concurrently with a per-probe 15 s wall-clock cap
  (so total ≈ max(per-cap), not sum — fleet size doesn't matter)
  and is fail-soft per probe: a crash inside one printer's check
  emits `{"printer_id": N, "error": "..."}` for that entry rather
  than nuking the whole snapshot. The snapshot is then added as a
  `diagnostics` top-level key by `_collect_support_info()`, so both
  flows (POST /support/bundle and POST /bug-report/submit via
  `support_info=...`) pick it up without their own changes.

  Private-data sanitization
  -------------------------
  The diagnostic schemas embed raw IPv4 in five field shapes that
  must not land in a submitted GitHub issue or a shared support ZIP:

    - PrinterDiagnosticResult.ip_address (top-level)
    - DiagnosticCheck.params.printer_ip (network-mode check)
    - DiagnosticCheck.params.host_ip (network-mode check)
    - VPDiagnosticResult per-check params.bind_ip (VP setup)
    - IPs embedded in log-health sample lines

  The first two carry the printer's own IP (already in the
  existing `collect_sensitive_strings` table via the Printer rows);
  host_ip and bind_ip are NOT in the DB so a sensitive_strings-only
  pass missed them.

  Fix: `_sanitize_recursive` walks the full snapshot tree, masks
  DB-known values with the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]`
  labels the log sanitizer applies (via the shared
  `collect_sensitive_strings`), then an IPv4-regex pass catches any
  IP the DB didn't cover — most importantly the Bambuddy host IP
  returned by `_get_host_ip()` and the VP `bind_ip` the user picked
  at setup. Recursive walk so arbitrary nested dicts/lists don't
  slip through future schema additions.

  Live-DB smoke test against the dev fleet: zero raw IPv4 instances
  in the serialized snapshot output; all five field shapes plus the
  embedded log samples render as `[IP]`.

  Progress indicators
  -------------------
  The bubble's "submitting" view and the System page's Download
  button now render a static four-line checklist showing what's
  running (printer connectivity → VP setup → log scan →
  submit / build ZIP). Static, not faked phase progress — we can't
  actually track server-side phases without SSE and the honest
  "here's what's happening" list communicates the longer wait
  without lying about percentage complete.

  9 new i18n keys, real translations in all 9 locales (no English
  fallback). parity script clean at 4993 leaves per locale.

  Tests: 6 new in test_diagnostic_snapshot.py
    - empty-input shape stable (the three top-level keys always present)
    - per-printer / per-VP result coverage (lists match input lengths)
    - fail-soft on a single-probe crash (other entries + log-health
      still complete)
    - timed_out marker when a probe exceeds the per-probe cap
      (test patches the cap to 0.05 s)
    - end-to-end IP sanitization across all five field shapes plus
      log-sample IPs, with a final JSON-serialize-and-regex sweep
      asserting zero raw IPv4 escapes anywhere in the result
    - concurrent execution proof (4 × 0.2 s probes complete in
      < 0.5 s; sequential would be 0.8 s)
maziggy преди 3 дни
родител
ревизия
3b9633a178

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
CHANGELOG.md


+ 16 - 0
backend/app/api/routes/support.py

@@ -1099,6 +1099,22 @@ async def _collect_support_info() -> dict:
     except Exception:
         logger.debug("Failed to collect WebSocket info", exc_info=True)
 
+    # Active diagnostics — per-printer connection check, per-VP setup check,
+    # and the log-health scan. These all surface in the UI today (System page +
+    # bug-report bubble) but were never persisted into what the maintainer
+    # receives, so a "looks broken in bambuddy" report arrived with no
+    # actionable signal beyond raw logs. The snapshot helper is fail-soft per
+    # probe and bounded by a per-probe wall-clock cap, so a hung interface
+    # adds at most ~15 s to bundle generation regardless of fleet size (probes
+    # run concurrently).
+    try:
+        from backend.app.services.diagnostic_snapshot import collect_diagnostic_snapshot
+
+        async with async_session() as db:
+            info["diagnostics"] = await collect_diagnostic_snapshot(db)
+    except Exception:
+        logger.warning("Failed to collect diagnostic snapshot", exc_info=True)
+
     return info
 
 

+ 207 - 0
backend/app/services/diagnostic_snapshot.py

@@ -0,0 +1,207 @@
+"""Aggregate connection, virtual-printer, and log-health diagnostics into a
+single snapshot for the support bundle and bug-report submission paths.
+
+Each user-triggered support artifact (the System-page support ZIP and the
+bug-report bubble) already exposed these three checks inline in the UI but
+omitted them from what landed in the maintainer's hands. This module is the
+single entry point both flows call to capture all three at once.
+
+Designed around three constraints:
+
+- **Fail-soft per probe.** A crash inside one printer's check must not nuke the
+  whole snapshot — that's the whole point of including diagnostics in the
+  bundle: a partial result is more useful than a 500.
+- **Bounded total runtime.** Each probe runs concurrently and is guarded by an
+  outer wall-clock cap; timeouts emit a marker entry rather than blocking.
+- **No mutation.** Connection / VP diagnostics only probe TCP ports and read
+  state; log-health is a passive scanner. Safe to run on every bundle.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+from typing import Any
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+logger = logging.getLogger(__name__)
+
+# Mirrors the IPv4 pattern in services.log_reader.sanitize_log_content. Kept as
+# a literal here (not imported) so a refactor of that module's internals can't
+# silently change snapshot sanitization. Skips firmware-version-shaped strings
+# (leading-zero octets like "01.09.01.00") via the [1-9]\d|\d alternations.
+_IPV4_RE = re.compile(r"\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\b")
+
+# Per-diagnostic wall-clock cap. Each underlying probe carries its own (smaller)
+# TCP / HTTP timeouts; this is the outer guard so a hung interface or a wedged
+# subprocess can't stall bundle generation past about this many seconds per
+# printer/VP. Snapshot total runtime is bounded by max(per-cap) thanks to the
+# concurrent gather, not the sum.
+_PER_DIAGNOSTIC_TIMEOUT_SECONDS = 15.0
+
+
+def _serialize(result: Any) -> Any:
+    """Convert a Pydantic model to a dict; pass through plain dicts/lists."""
+    if hasattr(result, "model_dump"):
+        return result.model_dump()
+    return result
+
+
+async def _run_connection_for(printer) -> dict:
+    from backend.app.services.printer_diagnostic import run_connection_diagnostic
+
+    base = {"printer_id": printer.id, "printer_name": printer.name}
+    try:
+        result = await asyncio.wait_for(
+            run_connection_diagnostic(
+                printer.ip_address,
+                printer=printer,
+                serial_number=printer.serial_number,
+                access_code=printer.access_code,
+            ),
+            timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
+        )
+        return {**base, "result": _serialize(result)}
+    except asyncio.TimeoutError:
+        return {**base, "error": "timed_out"}
+    except Exception as e:
+        # Log with traceback so the bundle generation isn't silent about
+        # a broken probe, but never propagate.
+        logger.warning("Connection diagnostic failed for printer %s: %s", printer.id, e, exc_info=True)
+        return {**base, "error": str(e)}
+
+
+async def _run_vp_for(vp) -> dict:
+    from backend.app.services.virtual_printer import virtual_printer_manager
+    from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
+
+    base = {"vp_id": vp.id, "name": vp.name}
+    try:
+        instance = virtual_printer_manager.get_instance(vp.id)
+        result = await asyncio.wait_for(
+            run_vp_diagnostic(vp, instance),
+            timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
+        )
+        return {**base, "result": _serialize(result)}
+    except asyncio.TimeoutError:
+        return {**base, "error": "timed_out"}
+    except Exception as e:
+        logger.warning("VP diagnostic failed for VP %s: %s", vp.id, e, exc_info=True)
+        return {**base, "error": str(e)}
+
+
+async def _run_log_health() -> Any:
+    from backend.app.services.log_health import scan_logs
+
+    try:
+        # scan_logs is sync I/O-bound (file read + regex); push off the loop.
+        result = await asyncio.wait_for(
+            asyncio.to_thread(scan_logs),
+            timeout=_PER_DIAGNOSTIC_TIMEOUT_SECONDS,
+        )
+        return _serialize(result)
+    except asyncio.TimeoutError:
+        return {"error": "timed_out"}
+    except Exception as e:
+        logger.warning("Log-health scan failed: %s", e, exc_info=True)
+        return {"error": str(e)}
+
+
+async def collect_diagnostic_snapshot(db: AsyncSession) -> dict[str, Any]:
+    """Return the three-section diagnostic snapshot.
+
+    Always returns a dict with keys ``connection_diagnostics`` (list, one entry
+    per active printer), ``vp_diagnostics`` (list, one entry per enabled VP —
+    empty if none), and ``log_health`` (the ``scan_logs`` result or an error
+    marker). Each list entry carries either ``result`` (success) or ``error``
+    (timeout / exception) so the maintainer can tell at a glance whether a
+    given probe ran.
+    """
+    from backend.app.models.printer import Printer
+    from backend.app.models.virtual_printer import VirtualPrinter
+
+    printers_result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
+    printers = list(printers_result.scalars().all())
+
+    vps_result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.enabled.is_(True)))
+    vps = list(vps_result.scalars().all())
+
+    # Concurrent: total wall-clock ≈ max(per-cap), not sum.
+    results = await asyncio.gather(
+        asyncio.gather(*(_run_connection_for(p) for p in printers)) if printers else _noop_list(),
+        asyncio.gather(*(_run_vp_for(vp) for vp in vps)) if vps else _noop_list(),
+        _run_log_health(),
+        return_exceptions=True,
+    )
+    connection_results, vp_results, log_health = results
+
+    def _coerce_list(r) -> list:
+        if isinstance(r, BaseException):
+            logger.warning("Diagnostic snapshot batch failed: %s", r)
+            return []
+        return list(r) if r is not None else []
+
+    snapshot = {
+        "connection_diagnostics": _coerce_list(connection_results),
+        "vp_diagnostics": _coerce_list(vp_results),
+        "log_health": log_health if not isinstance(log_health, BaseException) else {"error": str(log_health)},
+    }
+
+    # Sanitize before returning. The diagnostic schemas embed printer/host IPs
+    # (`PrinterDiagnosticResult.ip_address`, network-mode check params, VP
+    # `bind_ip`) and the snapshot adds printer names — none of which should
+    # leak into a submitted GitHub issue or a shared support ZIP. Use the
+    # same `collect_sensitive_strings` table the log sanitizer already
+    # consults so the replacement labels stay consistent ([PRINTER], [SERIAL],
+    # [IP], [ACCESS_CODE]); the IPv4 regex fallback in `_mask_string` then
+    # catches host / bind IPs that aren't in the DB.
+    try:
+        from backend.app.services.log_reader import collect_sensitive_strings
+
+        sensitive_strings = await collect_sensitive_strings(db)
+    except Exception:
+        logger.warning("Could not collect sensitive strings for snapshot sanitization", exc_info=True)
+        sensitive_strings = {}
+    return _sanitize_recursive(snapshot, sensitive_strings)
+
+
+async def _noop_list() -> list:
+    return []
+
+
+def _mask_string(value: str, sensitive_strings: dict[str, str]) -> str:
+    """Apply known-value replacement + IPv4 regex masking to a single string.
+
+    Known values are matched first (longest first so "My Printer 1" beats
+    "My Printer"); the regex pass then catches any IPs the sensitive_strings
+    table didn't already cover — most importantly the Bambuddy host's own
+    IP (returned by ``_get_host_ip`` inside the diagnostic, not in the DB)
+    and any virtual-printer ``bind_ip`` the user picked at setup.
+    """
+    if not value:
+        return value
+    for raw, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
+        if len(raw) < 3:
+            continue
+        if raw in value:
+            value = value.replace(raw, label)
+    value = _IPV4_RE.sub("[IP]", value)
+    return value
+
+
+def _sanitize_recursive(node: Any, sensitive_strings: dict[str, str]) -> Any:
+    """Walk the snapshot and redact strings in place — dicts, lists, scalars.
+
+    Non-string scalars (ints, bools, None) pass through; we only need to
+    mask user-visible values. Keys are NOT renamed (those are structural).
+    """
+    if isinstance(node, str):
+        return _mask_string(node, sensitive_strings)
+    if isinstance(node, dict):
+        return {k: _sanitize_recursive(v, sensitive_strings) for k, v in node.items()}
+    if isinstance(node, list):
+        return [_sanitize_recursive(item, sensitive_strings) for item in node]
+    return node

+ 291 - 0
backend/tests/unit/test_diagnostic_snapshot.py

@@ -0,0 +1,291 @@
+"""Tests for the diagnostic snapshot helper that aggregates connection,
+virtual-printer, and log-health diagnostics for the support bundle and
+bug-report submission paths (#1506 follow-up).
+
+The helper has three hard requirements:
+
+- Always returns the three top-level keys, even when sections are empty.
+- Fail-soft per probe — a single crash doesn't break the snapshot.
+- Bounded total runtime — concurrent gather caps wall-clock to the slowest probe.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.diagnostic_snapshot import collect_diagnostic_snapshot
+
+
+def _make_db_with_printers_and_vps(printers: list, vps: list):
+    """Stub AsyncSession whose two .execute() calls return printers then VPs."""
+    printers_result = MagicMock()
+    printers_result.scalars.return_value.all.return_value = printers
+    vps_result = MagicMock()
+    vps_result.scalars.return_value.all.return_value = vps
+    db = MagicMock()
+    # Two execute calls — printer query, then VP query (order matches the
+    # helper). side_effect cycles through the queue.
+    db.execute = AsyncMock(side_effect=[printers_result, vps_result])
+    return db
+
+
+@pytest.mark.asyncio
+async def test_snapshot_always_returns_three_top_level_keys_when_empty():
+    """No printers, no VPs — still get the three keys (empty lists, empty
+    log-health). Callers downstream rely on the shape being stable."""
+    db = _make_db_with_printers_and_vps([], [])
+    with patch(
+        "backend.app.services.diagnostic_snapshot._run_log_health",
+        new=AsyncMock(return_value={"findings": []}),
+    ):
+        out = await collect_diagnostic_snapshot(db)
+    assert set(out.keys()) == {"connection_diagnostics", "vp_diagnostics", "log_health"}
+    assert out["connection_diagnostics"] == []
+    assert out["vp_diagnostics"] == []
+    assert out["log_health"] == {"findings": []}
+
+
+@pytest.mark.asyncio
+async def test_snapshot_runs_diagnostic_per_active_printer():
+    """Each active printer gets a connection check; each enabled VP gets a
+    setup check. Result list length matches the input lists."""
+    printers = [
+        SimpleNamespace(id=1, name="P1S", ip_address="192.168.1.10", serial_number="01S00A", access_code="abc123"),
+        SimpleNamespace(id=2, name="X1C", ip_address="192.168.1.11", serial_number="C11Y00", access_code="xyz456"),
+    ]
+    vps = [SimpleNamespace(id=10, name="VP-1")]
+    db = _make_db_with_printers_and_vps(printers, vps)
+
+    fake_conn = SimpleNamespace(model_dump=lambda: {"checks": []})
+    fake_vp = SimpleNamespace(model_dump=lambda: {"checks": []})
+
+    with (
+        patch(
+            "backend.app.services.printer_diagnostic.run_connection_diagnostic",
+            new=AsyncMock(return_value=fake_conn),
+        ),
+        patch(
+            "backend.app.services.virtual_printer.virtual_printer_manager.get_instance",
+            return_value=None,
+        ),
+        patch(
+            "backend.app.services.virtual_printer.diagnostic.run_vp_diagnostic",
+            new=AsyncMock(return_value=fake_vp),
+        ),
+        patch(
+            "backend.app.services.diagnostic_snapshot._run_log_health",
+            new=AsyncMock(return_value={"findings": []}),
+        ),
+    ):
+        out = await collect_diagnostic_snapshot(db)
+
+    assert len(out["connection_diagnostics"]) == 2
+    assert out["connection_diagnostics"][0]["printer_id"] == 1
+    assert out["connection_diagnostics"][1]["printer_id"] == 2
+    assert all("result" in entry for entry in out["connection_diagnostics"])
+    assert len(out["vp_diagnostics"]) == 1
+    assert out["vp_diagnostics"][0]["vp_id"] == 10
+    assert "result" in out["vp_diagnostics"][0]
+
+
+@pytest.mark.asyncio
+async def test_snapshot_fails_soft_when_single_printer_diagnostic_raises():
+    """A crash inside one printer's diagnostic emits an error marker for that
+    printer, but the snapshot's other sections still complete. This is the
+    whole point of including diagnostics in the bundle — a partial result
+    beats a 500."""
+    printers = [
+        SimpleNamespace(id=1, name="ok", ip_address="1.1.1.1", serial_number="s1", access_code="a"),
+        SimpleNamespace(id=2, name="bad", ip_address="2.2.2.2", serial_number="s2", access_code="b"),
+    ]
+    db = _make_db_with_printers_and_vps(printers, [])
+
+    fake_ok = SimpleNamespace(model_dump=lambda: {"status": "ok"})
+
+    async def diag(ip_address, **_):
+        if ip_address == "2.2.2.2":
+            raise RuntimeError("simulated crash")
+        return fake_ok
+
+    with (
+        patch("backend.app.services.printer_diagnostic.run_connection_diagnostic", new=AsyncMock(side_effect=diag)),
+        patch(
+            "backend.app.services.diagnostic_snapshot._run_log_health",
+            new=AsyncMock(return_value={"findings": []}),
+        ),
+    ):
+        out = await collect_diagnostic_snapshot(db)
+
+    # Both printers represented; the crashing one carries an `error` field.
+    assert len(out["connection_diagnostics"]) == 2
+    ok_entry = next(e for e in out["connection_diagnostics"] if e["printer_id"] == 1)
+    bad_entry = next(e for e in out["connection_diagnostics"] if e["printer_id"] == 2)
+    assert "result" in ok_entry
+    assert "error" not in ok_entry
+    assert "error" in bad_entry
+    assert "simulated crash" in bad_entry["error"]
+    # Log-health still completes despite the per-printer crash.
+    assert out["log_health"] == {"findings": []}
+
+
+@pytest.mark.asyncio
+async def test_snapshot_emits_timed_out_marker_when_probe_exceeds_cap():
+    """If a single probe stalls past the per-diagnostic timeout, the entry
+    is marked `timed_out` rather than blocking the whole snapshot. Patch
+    the timeout small so the test runs fast."""
+    printers = [SimpleNamespace(id=1, name="slow", ip_address="1.1.1.1", serial_number="s", access_code="a")]
+    db = _make_db_with_printers_and_vps(printers, [])
+
+    async def slow_diag(*a, **k):
+        import asyncio
+
+        await asyncio.sleep(5)  # well past the patched cap below
+        return SimpleNamespace(model_dump=lambda: {})
+
+    with (
+        patch(
+            "backend.app.services.printer_diagnostic.run_connection_diagnostic", new=AsyncMock(side_effect=slow_diag)
+        ),
+        patch("backend.app.services.diagnostic_snapshot._PER_DIAGNOSTIC_TIMEOUT_SECONDS", 0.05),
+        patch(
+            "backend.app.services.diagnostic_snapshot._run_log_health",
+            new=AsyncMock(return_value={"findings": []}),
+        ),
+    ):
+        out = await collect_diagnostic_snapshot(db)
+
+    assert len(out["connection_diagnostics"]) == 1
+    assert out["connection_diagnostics"][0]["error"] == "timed_out"
+
+
+@pytest.mark.asyncio
+async def test_snapshot_masks_ip_addresses_in_all_diagnostic_fields():
+    """The diagnostic schemas embed raw IPv4 in three places — the top-level
+    ``PrinterDiagnosticResult.ip_address``, the network-mode check's
+    ``params.{printer_ip, host_ip}``, and the VP diagnostic's
+    ``params.bind_ip``. None of those should leak into the submitted
+    snapshot. Sanitization runs after the per-probe gather; both DB-known
+    IPs (covered by sensitive_strings → "[IP]") and host / VP-bind IPs
+    (caught by the IPv4 regex fallback) end up redacted.
+    """
+    printers = [
+        SimpleNamespace(
+            id=1, name="Workshop", ip_address="192.168.255.131", serial_number="01S00ABC123", access_code="abcd1234"
+        )
+    ]
+    vps = [SimpleNamespace(id=10, name="VP-Workshop")]
+    db = _make_db_with_printers_and_vps(printers, vps)
+
+    fake_conn = SimpleNamespace(
+        model_dump=lambda: {
+            "ip_address": "192.168.255.131",
+            "overall": "ok",
+            "checks": [
+                {
+                    "id": "network_mode",
+                    "status": "warn",
+                    "params": {"printer_ip": "192.168.255.131", "host_ip": "192.168.255.16"},
+                }
+            ],
+        }
+    )
+    fake_vp = SimpleNamespace(
+        model_dump=lambda: {
+            "overall": "ok",
+            "checks": [{"id": "bind_interface", "status": "pass", "params": {"bind_ip": "192.168.254.2"}}],
+        }
+    )
+
+    with (
+        patch(
+            "backend.app.services.log_reader.collect_sensitive_strings",
+            new=AsyncMock(
+                return_value={
+                    "Workshop": "[PRINTER]",
+                    "192.168.255.131": "[IP]",
+                    "01S00ABC123": "[SERIAL]",
+                    "abcd1234": "[ACCESS_CODE]",
+                }
+            ),
+        ),
+        patch(
+            "backend.app.services.printer_diagnostic.run_connection_diagnostic", new=AsyncMock(return_value=fake_conn)
+        ),
+        patch("backend.app.services.virtual_printer.virtual_printer_manager.get_instance", return_value=None),
+        patch("backend.app.services.virtual_printer.diagnostic.run_vp_diagnostic", new=AsyncMock(return_value=fake_vp)),
+        patch(
+            "backend.app.services.diagnostic_snapshot._run_log_health",
+            new=AsyncMock(return_value={"findings": [{"sample": "Connecting to 10.0.0.5..."}]}),
+        ),
+    ):
+        out = await collect_diagnostic_snapshot(db)
+
+    # Conection diagnostic — top-level ip_address and check params both masked.
+    conn_entry = out["connection_diagnostics"][0]
+    assert conn_entry["printer_name"] == "[PRINTER]"
+    assert conn_entry["result"]["ip_address"] == "[IP]"
+    check_params = conn_entry["result"]["checks"][0]["params"]
+    assert check_params["printer_ip"] == "[IP]"
+    assert check_params["host_ip"] == "[IP]"  # not in DB; caught by regex fallback
+
+    # VP diagnostic — bind_ip masked (regex fallback; never in DB).
+    vp_entry = out["vp_diagnostics"][0]
+    assert vp_entry["result"]["checks"][0]["params"]["bind_ip"] == "[IP]"
+
+    # Log-health findings — IPs in log samples also masked (regex applies
+    # recursively through the dict, not just to known fields).
+    assert "10.0.0.5" not in str(out["log_health"])
+    assert "[IP]" in out["log_health"]["findings"][0]["sample"]
+
+    # Sanity: no raw IPv4 anywhere in the serialized snapshot.
+    import json
+    import re as _re
+
+    serialized = json.dumps(out)
+    raw_ipv4 = _re.search(
+        r"\b(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}\b", serialized
+    )
+    assert raw_ipv4 is None, f"raw IPv4 leaked into snapshot: {raw_ipv4.group()}"
+
+
+@pytest.mark.asyncio
+async def test_snapshot_runs_probes_concurrently_not_sequentially():
+    """Total wall-clock for N printers should be O(slowest), not O(sum) —
+    this is what makes the feature usable on a fleet. Set each probe to
+    take 0.2 s; with 4 printers, sequential is 0.8 s, concurrent is 0.2 s.
+    Allow margin for scheduling and the test still catches a regression
+    to sequential execution.
+    """
+    import time
+
+    printers = [
+        SimpleNamespace(id=i, name=f"P{i}", ip_address=f"1.1.1.{i}", serial_number=f"s{i}", access_code="a")
+        for i in range(4)
+    ]
+    db = _make_db_with_printers_and_vps(printers, [])
+
+    async def slow_diag(*a, **k):
+        import asyncio
+
+        await asyncio.sleep(0.2)
+        return SimpleNamespace(model_dump=lambda: {"ok": True})
+
+    with (
+        patch(
+            "backend.app.services.printer_diagnostic.run_connection_diagnostic", new=AsyncMock(side_effect=slow_diag)
+        ),
+        patch(
+            "backend.app.services.diagnostic_snapshot._run_log_health",
+            new=AsyncMock(return_value={"findings": []}),
+        ),
+    ):
+        start = time.monotonic()
+        out = await collect_diagnostic_snapshot(db)
+        elapsed = time.monotonic() - start
+
+    assert len(out["connection_diagnostics"]) == 4
+    # Concurrent should be ~0.2 s; sequential would be ~0.8 s. Use 0.5 s
+    # as the threshold — slack enough for slow CI, tight enough to catch
+    # a regression to sequential execution.
+    assert elapsed < 0.5, f"snapshot ran sequentially: {elapsed:.2f}s for 4 x 0.2s probes"

+ 15 - 2
frontend/src/components/BugReportBubble.tsx

@@ -487,11 +487,24 @@ export function BugReportBubble() {
               )}
 
               {(viewState === 'stopping' || viewState === 'submitting') && (
-                <div className="flex flex-col items-center justify-center py-8 gap-3">
+                <div className="flex flex-col items-center justify-center py-6 gap-3">
                   <Loader2 className="w-8 h-8 animate-spin text-blue-500" />
-                  <p className="text-sm text-gray-600 dark:text-gray-400">
+                  <p className="text-sm text-gray-600 dark:text-gray-400 text-center">
                     {viewState === 'stopping' ? t('bugReport.stoppingLogs') : t('bugReport.submitting')}
                   </p>
+                  {viewState === 'submitting' && (
+                    // Diagnostics are run server-side inside the submit call
+                    // (#1506 follow-up): the bubble already displays current
+                    // results inline, but the submitted report now also
+                    // includes a snapshot. Wait is bounded but noticeable —
+                    // list what's running so the user knows why.
+                    <ul className="text-xs text-gray-500 dark:text-gray-400 list-disc list-inside space-y-0.5">
+                      <li>{t('bugReport.submittingStepConnection')}</li>
+                      <li>{t('bugReport.submittingStepVirtualPrinters')}</li>
+                      <li>{t('bugReport.submittingStepLogScan')}</li>
+                      <li>{t('bugReport.submittingStepSubmit')}</li>
+                    </ul>
+                  )}
                 </div>
               )}
 

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

@@ -3054,6 +3054,11 @@ export default {
     collectItem10: 'Python-Paketversionen',
     collectItem11: 'Datenbankzustandsprüfungen',
     collectItem12: 'Docker-Umgebungsdetails',
+    bundleGenerating: 'Bundle wird erstellt...',
+    bundleStepConnection: 'Drucker-Verbindungsprüfungen werden ausgeführt',
+    bundleStepVirtualPrinters: 'Setup-Prüfungen für virtuelle Drucker werden ausgeführt',
+    bundleStepLogScan: 'Aktuelle Protokolle werden auf bekannte Probleme überprüft',
+    bundleStepBuild: 'Support-Bundle-ZIP wird erstellt',
   },
 
   // File manager
@@ -5680,6 +5685,10 @@ export default {
     maxDuration: 'Stoppt automatisch nach {{minutes}} Min.',
     stoppingLogs: 'Protokolle sammeln & senden...',
     submitting: 'Fehlerbericht wird gesendet...',
+    submittingStepConnection: 'Drucker-Verbindungsprüfungen werden ausgeführt',
+    submittingStepVirtualPrinters: 'Setup-Prüfungen für virtuelle Drucker werden ausgeführt',
+    submittingStepLogScan: 'Aktuelle Protokolle werden auf bekannte Probleme überprüft',
+    submittingStepSubmit: 'Bericht wird an GitHub gesendet',
     submitSuccess: 'Fehlerbericht erfolgreich gesendet!',
     submitFailed: 'Fehlerbericht konnte nicht gesendet werden',
     diagnosticChecking: 'Druckerverbindungen werden geprüft...',

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

@@ -3057,6 +3057,11 @@ export default {
     collectItem10: 'Python package versions',
     collectItem11: 'Database health checks',
     collectItem12: 'Docker environment details',
+    bundleGenerating: 'Generating bundle...',
+    bundleStepConnection: 'Running printer connectivity checks',
+    bundleStepVirtualPrinters: 'Running virtual-printer setup checks',
+    bundleStepLogScan: 'Scanning recent logs for known issues',
+    bundleStepBuild: 'Building the support bundle ZIP',
   },
 
   // File manager
@@ -5691,6 +5696,10 @@ export default {
     maxDuration: 'Auto-stops after {{minutes}} min',
     stoppingLogs: 'Collecting logs & submitting...',
     submitting: 'Submitting bug report...',
+    submittingStepConnection: 'Running printer connectivity checks',
+    submittingStepVirtualPrinters: 'Running virtual-printer setup checks',
+    submittingStepLogScan: 'Scanning recent logs for known issues',
+    submittingStepSubmit: 'Submitting report to GitHub',
     submitSuccess: 'Bug report submitted successfully!',
     submitFailed: 'Failed to submit bug report',
     diagnosticChecking: 'Checking printer connections...',

+ 9 - 0
frontend/src/i18n/locales/es.ts

@@ -3057,6 +3057,11 @@ export default {
     collectItem10: 'Versiones de los paquetes de Python',
     collectItem11: 'Comprobaciones de estado de la base de datos',
     collectItem12: 'Detalles del entorno de Docker',
+    bundleGenerating: 'Generando paquete...',
+    bundleStepConnection: 'Ejecutando comprobaciones de conectividad de impresoras',
+    bundleStepVirtualPrinters: 'Ejecutando comprobaciones de configuración de impresoras virtuales',
+    bundleStepLogScan: 'Analizando los registros recientes en busca de problemas conocidos',
+    bundleStepBuild: 'Creando el archivo ZIP del paquete de soporte',
   },
 
   // File manager
@@ -5689,6 +5694,10 @@ export default {
     maxDuration: 'Se detiene automáticamente tras {{minutes}} min',
     stoppingLogs: 'Recopilando registros y enviando...',
     submitting: 'Enviando el informe de error...',
+    submittingStepConnection: 'Ejecutando comprobaciones de conectividad de impresoras',
+    submittingStepVirtualPrinters: 'Ejecutando comprobaciones de configuración de impresoras virtuales',
+    submittingStepLogScan: 'Analizando los registros recientes en busca de problemas conocidos',
+    submittingStepSubmit: 'Enviando el informe a GitHub',
     submitSuccess: '¡Informe de error enviado correctamente!',
     submitFailed: 'Error al enviar el informe de error',
     diagnosticChecking: 'Comprobando las conexiones de las impresoras...',

+ 9 - 0
frontend/src/i18n/locales/fr.ts

@@ -3043,6 +3043,11 @@ export default {
     collectItem10: 'Versions packages Python',
     collectItem11: 'Santé base de données',
     collectItem12: 'Détails environnement Docker',
+    bundleGenerating: 'Génération du paquet...',
+    bundleStepConnection: 'Vérification de la connectivité des imprimantes',
+    bundleStepVirtualPrinters: 'Vérification de la configuration des imprimantes virtuelles',
+    bundleStepLogScan: 'Analyse des journaux récents pour les problèmes connus',
+    bundleStepBuild: 'Création du ZIP du paquet de support',
   },
 
   // File manager
@@ -5670,6 +5675,10 @@ export default {
     maxDuration: 'Arrêt auto après {{minutes}} min',
     stoppingLogs: 'Collecte des journaux & envoi...',
     submitting: 'Envoi du rapport de bug...',
+    submittingStepConnection: 'Vérification de la connectivité des imprimantes',
+    submittingStepVirtualPrinters: 'Vérification de la configuration des imprimantes virtuelles',
+    submittingStepLogScan: 'Analyse des journaux récents pour les problèmes connus',
+    submittingStepSubmit: 'Envoi du rapport vers GitHub',
     submitSuccess: 'Rapport de bug envoyé avec succès !',
     submitFailed: 'Échec de l\'envoi du rapport de bug',
     diagnosticChecking: 'Vérification des connexions des imprimantes...',

+ 9 - 0
frontend/src/i18n/locales/it.ts

@@ -3042,6 +3042,11 @@ export default {
     collectItem10: 'Versioni dei pacchetti Python',
     collectItem11: 'Controlli di integrità del database',
     collectItem12: 'Dettagli dell\'ambiente Docker',
+    bundleGenerating: 'Generazione del pacchetto...',
+    bundleStepConnection: 'Controlli di connettività delle stampanti in corso',
+    bundleStepVirtualPrinters: 'Controlli di configurazione delle stampanti virtuali in corso',
+    bundleStepLogScan: 'Analisi dei log recenti per problemi noti',
+    bundleStepBuild: 'Creazione dello ZIP del pacchetto di supporto',
   },
 
   // File manager
@@ -5669,6 +5674,10 @@ export default {
     maxDuration: 'Arresto automatico dopo {{minutes}} min',
     stoppingLogs: 'Raccolta log & invio...',
     submitting: 'Invio segnalazione bug...',
+    submittingStepConnection: 'Controlli di connettività delle stampanti in corso',
+    submittingStepVirtualPrinters: 'Controlli di configurazione delle stampanti virtuali in corso',
+    submittingStepLogScan: 'Analisi dei log recenti per problemi noti',
+    submittingStepSubmit: 'Invio del report a GitHub',
     submitSuccess: 'Segnalazione bug inviata con successo!',
     submitFailed: 'Impossibile inviare la segnalazione bug',
     diagnosticChecking: 'Verifica delle connessioni delle stampanti...',

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

@@ -3054,6 +3054,11 @@ export default {
     collectItem10: 'Pythonパッケージバージョン',
     collectItem11: 'データベース健全性チェック',
     collectItem12: 'Docker環境の詳細',
+    bundleGenerating: 'バンドルを生成中...',
+    bundleStepConnection: 'プリンターの接続確認を実行中',
+    bundleStepVirtualPrinters: '仮想プリンターのセットアップ確認を実行中',
+    bundleStepLogScan: '最近のログから既知の問題をスキャン中',
+    bundleStepBuild: 'サポートバンドル ZIP を作成中',
   },
 
   // File manager
@@ -5681,6 +5686,10 @@ export default {
     maxDuration: '{{minutes}}分後に自動停止',
     stoppingLogs: 'ログ収集・送信中...',
     submitting: 'バグレポートを送信中...',
+    submittingStepConnection: 'プリンターの接続確認を実行中',
+    submittingStepVirtualPrinters: '仮想プリンターのセットアップ確認を実行中',
+    submittingStepLogScan: '最近のログから既知の問題をスキャン中',
+    submittingStepSubmit: 'GitHub にレポートを送信中',
     submitSuccess: 'バグレポートが正常に送信されました!',
     submitFailed: 'バグレポートの送信に失敗しました',
     diagnosticChecking: 'プリンター接続を確認中...',

+ 9 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3042,6 +3042,11 @@ export default {
     collectItem10: 'Versões de pacotes Python',
     collectItem11: 'Verificações de integridade do banco de dados',
     collectItem12: 'Detalhes do ambiente Docker',
+    bundleGenerating: 'Gerando pacote...',
+    bundleStepConnection: 'Executando verificações de conectividade das impressoras',
+    bundleStepVirtualPrinters: 'Executando verificações de configuração das impressoras virtuais',
+    bundleStepLogScan: 'Analisando registros recentes em busca de problemas conhecidos',
+    bundleStepBuild: 'Criando o ZIP do pacote de suporte',
   },
 
   // File manager
@@ -5669,6 +5674,10 @@ export default {
     maxDuration: 'Para automaticamente após {{minutes}} min',
     stoppingLogs: 'Coletando logs & enviando...',
     submitting: 'Enviando relatório de bug...',
+    submittingStepConnection: 'Executando verificações de conectividade das impressoras',
+    submittingStepVirtualPrinters: 'Executando verificações de configuração das impressoras virtuais',
+    submittingStepLogScan: 'Analisando registros recentes em busca de problemas conhecidos',
+    submittingStepSubmit: 'Enviando relatório para o GitHub',
     submitSuccess: 'Relatório de bug enviado com sucesso!',
     submitFailed: 'Falha ao enviar relatório de bug',
     diagnosticChecking: 'Verificando as conexões das impressoras...',

+ 9 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3042,6 +3042,11 @@ export default {
     collectItem10: 'Python 包版本',
     collectItem11: '数据库健康检查',
     collectItem12: 'Docker 环境详情',
+    bundleGenerating: '正在生成包...',
+    bundleStepConnection: '正在执行打印机连接性检查',
+    bundleStepVirtualPrinters: '正在执行虚拟打印机设置检查',
+    bundleStepLogScan: '正在扫描最近的日志以查找已知问题',
+    bundleStepBuild: '正在构建支持包 ZIP 文件',
   },
 
   // File manager
@@ -5668,6 +5673,10 @@ export default {
     maxDuration: '{{minutes}}分钟后自动停止',
     stoppingLogs: '正在收集日志并提交...',
     submitting: '正在提交错误报告...',
+    submittingStepConnection: '正在执行打印机连接性检查',
+    submittingStepVirtualPrinters: '正在执行虚拟打印机设置检查',
+    submittingStepLogScan: '正在扫描最近的日志以查找已知问题',
+    submittingStepSubmit: '正在将报告提交至 GitHub',
     submitSuccess: '错误报告提交成功!',
     submitFailed: '提交错误报告失败',
     diagnosticChecking: '正在检查打印机连接...',

+ 9 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3042,6 +3042,11 @@ export default {
     collectItem10: 'Python 套件版本',
     collectItem11: '資料庫健康檢查',
     collectItem12: 'Docker 環境詳情',
+    bundleGenerating: '正在產生套件...',
+    bundleStepConnection: '正在執行印表機連線檢查',
+    bundleStepVirtualPrinters: '正在執行虛擬印表機設定檢查',
+    bundleStepLogScan: '正在掃描最近的日誌以尋找已知問題',
+    bundleStepBuild: '正在建立支援套件 ZIP 檔案',
   },
 
   // File manager
@@ -5668,6 +5673,10 @@ export default {
     maxDuration: '{{minutes}} 分鐘後自動停止',
     stoppingLogs: '正在收集日誌並提交...',
     submitting: '正在提交錯誤報告...',
+    submittingStepConnection: '正在執行印表機連線檢查',
+    submittingStepVirtualPrinters: '正在執行虛擬印表機設定檢查',
+    submittingStepLogScan: '正在掃描最近的日誌以尋找已知問題',
+    submittingStepSubmit: '正在將報告提交至 GitHub',
     submitSuccess: '錯誤報告提交成功!',
     submitFailed: '提交錯誤報告失敗',
     diagnosticChecking: '正在檢查印表機連線...',

+ 22 - 1
frontend/src/pages/SystemInfoPage.tsx

@@ -303,10 +303,31 @@ export function SystemInfoPage() {
               title={!debugLoggingState?.enabled ? t('support.enableDebugFirst', 'Enable debug logging first') : undefined}
             >
               {bundleDownloading && <Loader2 className="w-4 h-4 animate-spin" />}
-              {t('common.download', 'Download')}
+              {bundleDownloading
+                ? t('support.bundleGenerating', 'Generating...')
+                : t('common.download', 'Download')}
             </button>
           </div>
 
+          {/* Progress indicator — bundle generation now runs connection +
+              virtual-printer diagnostics and the log-health scan before
+              writing the ZIP (#1506 follow-up), so the wait is longer than
+              a pure file-export. List what's running so it's not opaque. */}
+          {bundleDownloading && (
+            <div className="p-3 bg-bambu-dark-tertiary/40 rounded-lg space-y-1">
+              <p className="text-sm font-medium text-white flex items-center gap-2">
+                <Loader2 className="w-3.5 h-3.5 animate-spin text-bambu-green" />
+                {t('support.bundleGenerating', 'Generating...')}
+              </p>
+              <ul className="text-xs text-bambu-gray list-disc list-inside space-y-0.5 pl-1">
+                <li>{t('support.bundleStepConnection', 'Running printer connectivity checks')}</li>
+                <li>{t('support.bundleStepVirtualPrinters', 'Running virtual-printer setup checks')}</li>
+                <li>{t('support.bundleStepLogScan', 'Scanning recent logs for known issues')}</li>
+                <li>{t('support.bundleStepBuild', 'Building the support bundle ZIP')}</li>
+              </ul>
+            </div>
+          )}
+
           {/* Error message */}
           {bundleError && (
             <div className="p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-C-YgIh3u.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-y4woBlMv.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-8qalZ11b.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-HqxudbTf.css">
+    <script type="module" crossorigin src="/assets/index-C-YgIh3u.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-y4woBlMv.css">
   </head>
   <body>
     <div id="root"></div>

Някои файлове не бяха показани, защото твърде много файлове са промени