● 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)
This commit is contained in:
maziggy
2026-05-24 10:16:30 +02:00
parent 17602774f3
commit 3b9633a178
18 changed files with 648 additions and 18 deletions
+1
View File
@@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file.
- **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean).
### Fixed
- **Support bundle + bug-report submission now include the live diagnostic snapshot** — Three diagnostics (Connection Diagnostic per printer, Virtual Printer Setup Diagnostic per enabled VP, Log Health Scanner) have shipped on the System page and inline in the bug-report bubble since 6bc6a1d6 / e222a0ef / ed31b8f4, but the results were only ever shown to the *user* — never persisted into the downloadable support ZIP or the submitted GitHub issue. A report saying "looks broken in Bambuddy" arrived with no actionable signal beyond raw logs. **Fix**: new `services/diagnostic_snapshot.collect_diagnostic_snapshot` runs all three concurrently with an outer per-probe 15 s wall-clock cap (so a hung interface adds at most ~15 s to bundle generation regardless of fleet size — `asyncio.gather`, total ≈ max(per-cap) not sum). 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 — partial result beats a 500. Wired into `_collect_support_info()` so both flows (`POST /support/bundle` and `POST /bug-report/submit` via `support_info=...`) pick up the new `diagnostics` top-level key without their own changes. **Private-data sanitization** — the diagnostic schemas embed raw IPv4 in three places (`PrinterDiagnosticResult.ip_address`, network-mode check's `params.{printer_ip, host_ip}`, VP diagnostic's `params.bind_ip`), and the snapshot adds printer names. None of those should leak. The snapshot now runs a recursive sanitizer on the full result tree before returning: known DB-listed values (printer name, IP, serial, access code) get the same `[PRINTER]/[IP]/[SERIAL]/[ACCESS_CODE]` labels the log sanitizer already applies (via the shared `collect_sensitive_strings`), and an IPv4-regex fallback catches IPs the DB doesn't know about — most importantly the Bambuddy host IP returned by `_get_host_ip()` and any VP `bind_ip` the user picked at setup. Live-DB smoke test confirms zero raw IPv4 instances in the serialized snapshot output. **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) — communicates the longer wait honestly without faking server-side phase progress we can't actually track. **Tests**: 6 new in `test_diagnostic_snapshot.py` — empty-input shape stable, per-printer / per-VP result coverage, fail-soft on a single-probe crash, `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 (top-level `ip_address`, `printer_ip`, `host_ip`, `bind_ip`, plus IPs embedded in log-health sample lines) with a final regex sweep over the JSON-serialized result asserting zero raw IPv4 escapes, concurrent execution proof (4 × 0.2 s probes complete in < 0.5 s, would be 0.8 s sequential). Existing 27 BugReportBubble + SystemInfoPage frontend tests still pass; 9-locale i18n parity check clean (4993 leaves per locale, 9 new keys added with real translations everywhere — no English fallback). Backend ruff clean.
- **"Prefer Lowest Remaining Filament" now uses Bambuddy's inventory weight, not just the printer's RFID counter (#1508, reported by @kleinwareio)** — Reporter has an inventory spool cloned to slot 1 and the original (much further used) in slot 4 of the same P1S AMS, with the preference enabled, and the dispatch consistently picked slot 1 (the fresh clone) instead of slot 4 (the original they wanted to finish first). Root cause is the `prefer_lowest` sort in `_match_filaments_to_slots` (`print_scheduler.py`): the sort key reads `f.get("remain", -1)` straight out of `_build_loaded_filaments`, which sources it from MQTT AMS `tray.remain` — the printer firmware's own RFID-decremented value. Two problems with that signal: (a) it's only populated for Bambu RFID spools, so every non-RFID / 3rd-party / user-loaded tray reports `-1` and gets clamped to a sentinel — multiple non-RFID spools then tie in the sort and Python's stable sort collapses to AMS-slot insertion order, so slot 1 always wins; (b) even when set, it's the *printer's* counter, not Bambuddy's `label_weight - weight_used` (internal mode) or Spoolman's `remaining_weight` (Spoolman mode) — the two diverge any time the user re-spools, swaps cardboard, or runs a print outside Bambuddy. The reporter is on internal-inventory mode with non-RFID spools — both failure modes apply, hence slot 1 every time. **Fix**: when a slot is bound to a Bambuddy / Spoolman spool, that inventory record's remaining weight becomes the sort signal. New async helper `_build_inventory_remain_overrides(db, printer_id, loaded)` returns `{global_tray_id: remaining_grams}` for slots with an assignment — internal mode joins `SpoolAssignment` → `Spool` once per dispatch, Spoolman mode joins `SpoolmanSlotAssignment` then fetches each spool through the existing `_spoolman_remaining_grams` (shared with `filament_deficit.py`, parity rule per [[feedback_inventory_modes_parity]]). The new `_prefer_lowest_sort_key` consumes that map alongside the legacy MQTT field with a **two-tier** comparison: inventory-tracked spools always sort BEFORE MQTT-only spools, then ascending by remaining within each tier, then ascending by `ams_id * 4 + tray_id` as the deterministic slot tie-breaker. The tier flag dominates so we never compare grams (inventory) against percent (MQTT) — no unit-conversion contortions. MQTT-only behaviour is preserved exactly: `remain = -1` still maps to the 101 sentinel and slot order still decides on ties, so users who haven't bound any spools see no change. External / VT tray slots are skipped (tracked separately from AMS bindings). Lookup runs only when `prefer_lowest_filament` is enabled — no extra DB hit for users who don't use the preference. **Tests**: 6 new in `TestPreferLowestInventoryOverride` in `test_scheduler_ams_mapping.py` (inventory override beats MQTT remain — the literal reporter scenario with 950 g clone vs 50 g original; zero-grams still sorts first within its tier; inventory tier beats MQTT tier regardless of value; tied inventory grams break to lower slot; no-override falls through to MQTT — regression guard for un-tracked spools; legacy `remain = -1` still sentinel-sorts last when override map is None) + 7 new in `test_scheduler_inventory_remain.py` covering `_build_inventory_remain_overrides` directly (internal mode returns label_weight − weight_used per bound slot; external slots skipped; empty loaded short-circuits; over-consumed spool clamps to 0 g; unbound slots absent from map; Spoolman mode uses `_spoolman_remaining_grams` for parity; Spoolman unreachability silently omits that slot). 102 scheduler + inventory tests green; backend ruff clean.
- **X1/H2/P2 live camera no longer fails with `Address already in use` on transitional ffmpeg builds (#1504, reported by @rage03usa, confirmed by @PawseHaxor)** — On a native Ubuntu install with the Jammy-era system ffmpeg, the RTSP live-view path retried indefinitely with `Unable to open RTSP for listening … Address already in use`. Snapshots, the camera diagnostic, and OrcaSlicer all kept working — only live view was broken. Cause: the ffmpeg argv built in `backend/app/api/routes/camera.py` (added in 530a7a46 as part of an RTSP-stability bundle) passed `-timeout 30000000`. That ffmpeg version *deprecated* the original `-timeout` (socket I/O microseconds) and repurposed the name to mean the *RTSP listen-mode incoming-connection timeout* — any non-zero value **implies `-listen`**. ffmpeg then flipped into RTSP server mode and tried to bind the same localhost port Bambuddy's TLS proxy was already listening on, hence EADDRINUSE on every retry (the odd-port pattern @PawseHaxor noticed is coincidence — the ephemeral allocator just picked odd values that run). The reporter's own workaround (drop the option) works but silently loses the socket-level read timeout, so a hung TLS handshake would block past the OS TCP timeout instead of failing fast into the existing reconnect loop. **Why this can't be a one-line literal swap**: ffmpeg has shipped *three* arrangements of this option over time and Bambuddy supports the full range. Pre-deprecation builds: `-timeout` is the socket I/O timeout. Transitional builds (~late-4.x, what the reporter is on): `-timeout` is the broken listen-mode option, `-stimeout` is the replacement. **Modern ffmpeg (5.x / 6.x / 7.x — current Debian 13, Ubuntu 24.04, current Homebrew)**: `-stimeout` was removed entirely and `-timeout` is back to socket I/O. So both literals regress one half of the install base. **Fix**: a new `rtsp_socket_timeout_flag()` helper in `backend/app/services/camera.py` probes `ffmpeg -h demuxer=rtsp` once at first use and picks `-stimeout` when ffmpeg advertises it (transitional window) or `-timeout` otherwise (modern + very old). The result is cached for the process lifetime — ffmpeg won't swap mid-run. The function returns the option name without a leading dash so callers prepend it themselves (no empty-flag formatting bug). Wired into both RTSP ffmpeg call sites — `routes/camera.py` (printer camera) and `services/external_camera.py` (external RTSP) — in lockstep, same TLS-proxy + ffmpeg pattern, same regression. The reporter had tried `-listen_timeout` (doesn't help — we *don't* want listen mode) and `-rw_timeout` (AVIO-level, RTSP demuxer doesn't honour it on its control socket), but no manual swap could be correct for both transitional and modern installs simultaneously. **Tests**: 8 in `test_ffmpeg_rtsp_timeout_flag.py` — 6 unit tests for the probe (picks `-stimeout` when advertised, falls back to `-timeout` on modern, defaults to `-timeout` when ffmpeg missing or probe raises, caches across calls, substring-match guard against false-positives on `-listen_timeout`), 2 parametrised regression guards against either RTSP ffmpeg argv re-hard-coding a literal flag instead of consuming the probe. 37 (probe + existing external-camera) tests green; backend ruff clean.
- **SliceModal: process / filament dropdowns now filter by nozzle diameter too, not just printer model (#1325 follow-up #2, reported by @IndividualGhost1905)** — With the @BBL name fallback in place, the reporter saw that an X2D 0.4 selection still mixed 0.2 / 0.6 / 0.8 nozzle process variants into the main list. The fallback's regex stripped any trailing `<size> nozzle` suffix from both sides before comparing, so `"Bambu Lab X2D 0.4 nozzle"` and `"0.40mm Strength @BBL X2D 0.8 nozzle"` both reduced to `"X2D"` and matched. The bundle path was already nozzle-correct (a `.bbscfg` is scoped to one printer-preset-name including its nozzle, so the bundle-side exact-match was nozzle-aware); only the name fallback needed fixing. **Fix**: `extractPrinterPresetModel` and `extractBblToken` now each return `{ model, nozzle }`. The nozzle is the parsed string ("0.4" / "0.6" / etc.) or `null` when the name has no suffix. `classifyByBambuName` treats a `null` process nozzle as `"0.4"` — Bambu's convention is to omit the suffix on 0.4 (the default) and include it for 0.2 / 0.6 / 0.8, exactly as the reporter described. Both `model` and `nozzle` must compare equal for a `'match'`; differing nozzles fall into the existing "Other printers" group, no new group label needed. If the selected printer preset name has no parseable nozzle (non-Bambu / hand-typed), the matcher degrades to model-only — Bambu printer presets always include nozzle in practice, so this is defensive. **Tests**: 9 new in `slicerPrinterMatch.test.ts` covering the matrix (0.4 printer vs no-suffix / 0.6 / 0.8 process; 0.6 printer vs 0.6 / no-suffix-=-0.4; explicit 0.4-suffix-on-process still matches 0.4 printer; same rule on filament presets; wrong-model dominates over matching-nozzle; no-nozzle printer name degrades to model-only); one existing test reframed (the case that previously asserted a 0.6-nozzle process matched a 0.4 printer — the exact bug — now asserts mismatch). 46 slicerPrinterMatch + 34 SliceModal tests green; frontend build clean.
+16
View File
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>