mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
● 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 (6bc6a1d6VP setup diagnostic,e222a0eflog-health scanner,ed31b8f4connection 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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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: 'プリンター接続を確認中...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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: '正在检查打印机连接...',
|
||||
|
||||
@@ -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: '正在檢查印表機連線...',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user