15 Commits 6591fc011f ... 1e734fb7c6

Author SHA1 Message Date
  maziggy 1e734fb7c6 fix(stats): cancelled prints get their own bucket; gauge denominator excludes them (#1390 follow-up) 3 days ago
  maziggy b9b06a7351 chore(docker): silence Trivy DS-0026 on Dockerfile.test via HEALTHCHECK NONE 3 days ago
  maziggy 12d344cbfc ci(docker): full backend suite in Docker, 4-way matrix shard, GHA cache backend 3 days ago
  maziggy a4afc9c073 ci(docker): stop re-running unit tests inside the test image 3 days ago
  maziggy 0aadce1a8a ci(docker): drop -v, -n auto instead of -n 30, pip cache mount 3 days ago
  maziggy ca08f1f340 fix(test): stop sys.modules-deleting backend.app.main in test_code_quality 3 days ago
  maziggy 22f222e4ac fix(test): snapshot _timelapse_baselines inside the patch context to dodge CI race 3 days ago
  maziggy eb98521e93 fix(test): use /nonexistent/ instead of /tmp/ to satisfy Bandit B108 3 days ago
  maziggy 7fbde1ea3d test(docker): include gcode_viewer/ in the test image so the packaging assertion actually runs 3 days ago
  maziggy a64df5a922 Updated CHANGELOG 3 days ago
  maziggy b9d51ffd80 chore(deps): floor-pin starlette>=1.0.1 against PYSEC-2026-161 3 days ago
  maziggy 3b9633a178 ● feat(support): include sanitized connection / VP / log-health diagnostics in support bundle and bug report (#1506 follow-up) 3 days ago
  maziggy 17602774f3 Updated BACKERS.md 3 days ago
  maziggy 03896d1af0 fix(scheduler): use inventory weight for "Prefer Lowest Filament" sort (#1508) 3 days ago
  maziggy eae96da56e fix(camera): probe ffmpeg for the right RTSP socket-timeout flag (#1504) 3 days ago
41 changed files with 1701 additions and 52 deletions
  1. 71 17
      .github/workflows/ci.yml
  2. 2 0
      BACKERS.md
  3. 2 0
      CHANGELOG.md
  4. 27 4
      Dockerfile.test
  5. 0 0
      backend/=0.9.0
  6. 15 1
      backend/app/api/routes/archives.py
  7. 6 2
      backend/app/api/routes/camera.py
  8. 16 0
      backend/app/api/routes/support.py
  9. 4 0
      backend/app/schemas/archive.py
  10. 67 0
      backend/app/services/camera.py
  11. 207 0
      backend/app/services/diagnostic_snapshot.py
  12. 5 1
      backend/app/services/external_camera.py
  13. 19 2
      backend/app/services/failure_analysis.py
  14. 169 5
      backend/app/services/print_scheduler.py
  15. 1 1
      backend/tests/unit/services/test_filament_deficit.py
  16. 19 4
      backend/tests/unit/test_code_quality.py
  17. 291 0
      backend/tests/unit/test_diagnostic_snapshot.py
  18. 139 0
      backend/tests/unit/test_ffmpeg_rtsp_timeout_flag.py
  19. 264 0
      backend/tests/unit/test_scheduler_ams_mapping.py
  20. 194 0
      backend/tests/unit/test_scheduler_inventory_remain.py
  21. 10 1
      backend/tests/unit/test_timelapse_baseline_restart_recovery.py
  22. 3 1
      docker-compose.test.yml
  23. 17 6
      frontend/src/__tests__/pages/StatsPage.test.tsx
  24. 1 0
      frontend/src/api/client.ts
  25. 15 2
      frontend/src/components/BugReportBubble.tsx
  26. 10 0
      frontend/src/i18n/locales/de.ts
  27. 10 0
      frontend/src/i18n/locales/en.ts
  28. 10 0
      frontend/src/i18n/locales/es.ts
  29. 10 0
      frontend/src/i18n/locales/fr.ts
  30. 10 0
      frontend/src/i18n/locales/it.ts
  31. 10 0
      frontend/src/i18n/locales/ja.ts
  32. 10 0
      frontend/src/i18n/locales/pt-BR.ts
  33. 10 0
      frontend/src/i18n/locales/zh-CN.ts
  34. 10 0
      frontend/src/i18n/locales/zh-TW.ts
  35. 14 2
      frontend/src/pages/StatsPage.tsx
  36. 22 1
      frontend/src/pages/SystemInfoPage.tsx
  37. 4 0
      requirements-dev.txt
  38. 5 0
      requirements.txt
  39. 0 0
      static/assets/index-BW850Rth.js
  40. 0 0
      static/assets/index-y4woBlMv.css
  41. 2 2
      static/index.html

+ 71 - 17
.github/workflows/ci.yml

@@ -84,10 +84,17 @@ jobs:
             --ignore-vuln CVE-2025-45768
 
   backend-tests:
-    name: Backend Tests
+    name: Backend Tests (shard ${{ matrix.shard }}/4)
     runs-on: ubuntu-latest
     if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     needs: backend-lint
+    strategy:
+      # Don't cancel sibling shards if one fails — we want every shard's
+      # failure list, not just the first one, so a single PR push shows
+      # all broken tests in one go.
+      fail-fast: false
+      matrix:
+        shard: [1, 2, 3, 4]
     steps:
       - uses: actions/checkout@v4
 
@@ -110,11 +117,22 @@ jobs:
           pip install -r requirements.txt
           pip install -r requirements-dev.txt
 
-      - name: Run tests
+      - name: Run tests (shard ${{ matrix.shard }}/4)
         timeout-minutes: 10
         run: |
           cd backend
-          python -m pytest tests/ -v --tb=short --timeout=60 --timeout-method=thread -n auto
+          # -v dropped: 5300+ "PASSED foo::bar" lines per worker eat 30-60s
+          # of stdout I/O time on 2-vCPU runners. --tb=short is enough.
+          # --splits 4 --group N uses pytest-split to slice the collected
+          # test set roughly evenly across the 4 matrix shards; first run
+          # is name-hash-based, subsequent runs improve via .test_durations
+          # if you ever commit one (we don't — even the naive hash split
+          # gets us ≈25% per shard given the test mix here).
+          python -m pytest tests/ \
+            --tb=short \
+            --timeout=60 --timeout-method=thread \
+            -n auto \
+            --splits 4 --group ${{ matrix.shard }}
 
   # ============================================================================
   # Frontend Checks
@@ -261,6 +279,56 @@ jobs:
   # Docker Tests (matches test_docker.sh)
   # ============================================================================
 
+  # Run the FULL backend test suite inside the test image, sharded 4-way
+  # so wall-clock matches the host-side backend-tests job. Catches the
+  # rare-but-real cases where a test passes on the GHA host but fails in
+  # the python:3.13-slim test image (system-binary version differences,
+  # locale/timezone, container vs host user, cwd assumptions). Without
+  # sharding this was a 5-10 min single-runner job; with sharding it's
+  # ~120-150s per shard running in parallel, gated by max(shard).
+  docker-backend-tests:
+    name: Docker Backend Tests (shard ${{ matrix.shard }}/4)
+    runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
+    timeout-minutes: 15
+    strategy:
+      fail-fast: false
+      matrix:
+        shard: [1, 2, 3, 4]
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+
+      # Build the backend-test image with GHA BuildKit cache backend so
+      # the pip-install layer is shared across the 4 matrix shards AND
+      # across CI runs. First run on a given requirements.txt is cold
+      # (~60-90s); subsequent runs are ~5-10s.
+      - name: Build backend test image (cached)
+        uses: docker/build-push-action@v5
+        with:
+          context: .
+          file: Dockerfile.test
+          target: backend-test
+          load: true
+          tags: bambuddy-backend-test:latest
+          cache-from: type=gha,scope=backend-test
+          cache-to: type=gha,scope=backend-test,mode=max
+
+      - name: Run backend tests in Docker (shard ${{ matrix.shard }}/4)
+        run: |
+          docker run --rm \
+            -e TESTING=1 \
+            -e PYTHONUNBUFFERED=1 \
+            bambuddy-backend-test:latest \
+            pytest backend/tests/ \
+              --tb=short \
+              --timeout=60 --timeout-method=thread \
+              -p no:cacheprovider \
+              -n auto \
+              --splits 4 --group ${{ matrix.shard }}
+
   docker-test:
     name: Docker Build
     runs-on: ubuntu-latest
@@ -280,20 +348,6 @@ jobs:
       - name: Verify static files exist
         run: docker run --rm bambuddy:test test -d /app/static
 
-      # Test 2: Backend Unit Tests in Docker
-      - name: Build backend test image
-        run: docker compose -f docker-compose.test.yml build backend-test
-
-      - name: Run backend tests in Docker
-        run: docker compose -f docker-compose.test.yml run --rm backend-test
-
-      # Test 3: Frontend Unit Tests in Docker
-      - name: Build frontend test image
-        run: docker compose -f docker-compose.test.yml build frontend-test
-
-      - name: Run frontend tests in Docker
-        run: docker compose -f docker-compose.test.yml run --rm frontend-test
-
       # Test 4: Integration Tests
       - name: Build integration container
         run: docker compose -f docker-compose.test.yml build integration

+ 2 - 0
BACKERS.md

@@ -38,6 +38,8 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
 - [@grizz0blaw](https://github.com/grizz0blaw)
 - [@NoahTingey](https://github.com/NoahTingey)
 - [@sentinel-center](https://github.com/sentinel-center)
+- [@brianehlert](https://github.com/brianehlert)
+- [@siiruup](https://github.com/siiruup)
 ---
 
 ## One-time and historical supporters

File diff suppressed because it is too large
+ 2 - 0
CHANGELOG.md


+ 27 - 4
Dockerfile.test

@@ -1,4 +1,5 @@
 # Test image for running backend and frontend tests
+# syntax=docker/dockerfile:1.7
 FROM python:3.13-slim AS backend-test
 
 WORKDIR /app
@@ -8,15 +9,26 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     curl \
     && rm -rf /var/lib/apt/lists/*
 
-# Install Python dependencies including test dependencies
+# Install Python dependencies including test dependencies.
+# BuildKit cache mount makes subsequent builds re-use the pip download
+# cache so the second build only does install work — the slow ~60-90s
+# fetch step from the first build becomes ~5s. Requires DOCKER_BUILDKIT=1
+# (already set in test_docker.sh).
 COPY requirements.txt ./
 COPY requirements-dev.txt ./
-RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt
+RUN --mount=type=cache,target=/root/.cache/pip \
+    pip install -r requirements.txt -r requirements-dev.txt
 
 # Copy backend code
 COPY backend/ ./backend/
 COPY pyproject.toml ./
 
+# Embedded GCode viewer assets — required so the @app.get("/gcode-viewer/...")
+# packaging-regression test in tests/integration/test_gcode_viewer.py actually
+# runs instead of pytest-skipping with "index.html not present". Path matches
+# the production Dockerfile (static_dir.parent / "gcode_viewer" = /app/gcode_viewer/).
+COPY gcode_viewer/ ./gcode_viewer/
+
 # Create necessary directories
 RUN mkdir -p /app/data /app/logs /app/archive
 
@@ -25,8 +37,19 @@ ENV PYTHONUNBUFFERED=1
 ENV DATA_DIR=/app/data
 ENV TESTING=1
 
-# Default command runs pytest (excluding docker integration tests)
-CMD ["pytest", "backend/tests/", "-v", "--tb=short", "-p", "no:cacheprovider", "-n", "30"]
+# Test image runs pytest and exits — there is no long-running service
+# to probe. HEALTHCHECK NONE is the documented Docker opt-out and
+# silences Trivy DS-0026 without adding meaningless probe logic.
+HEALTHCHECK NONE
+
+# Default command runs pytest (excluding docker integration tests).
+# -v dropped: 5300+ "PASSED foo::bar" lines per worker eat noticeable
+# stdout I/O time and clutter test_docker.sh output. --tb=short still
+# gives full failure tracebacks when something breaks.
+# -n auto adapts to the host's vCPU count instead of hard-coding 30 —
+# on a 2-vCPU CI / VM runner, -n 30 spawns 30 Python processes fighting
+# for 2 cores, which is mostly IPC + import-thrash overhead.
+CMD ["pytest", "backend/tests/", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"]
 
 # -------------------------------------------
 # Frontend test stage

+ 0 - 0
backend/=0.9.0


+ 15 - 1
backend/app/api/routes/archives.py

@@ -865,10 +865,23 @@ async def get_archive_stats(
     successful_prints = successful_result.scalar() or 0
 
     failed_result = await db.execute(
-        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status == "failed", *base_conditions)
+        select(func.count(PrintLogEntry.id)).where(PrintLogEntry.status.in_(("failed", "aborted")), *base_conditions)
     )
     failed_prints = failed_result.scalar() or 0
 
+    # User/system-stopped prints — stopped/cancelled/skipped are distinct from
+    # quality failures: the user (or the queue) interrupted them, the printer
+    # didn't detect a fault. Bucketed separately so the Success Rate gauge
+    # divides by completed + failed only (a cancelled print shouldn't drag
+    # the gauge down), while still being visible in the breakdown so they
+    # don't silently vanish from Total Prints (#1390).
+    cancelled_result = await db.execute(
+        select(func.count(PrintLogEntry.id)).where(
+            PrintLogEntry.status.in_(("stopped", "cancelled", "skipped")), *base_conditions
+        )
+    )
+    cancelled_prints = cancelled_result.scalar() or 0
+
     # Total elapsed time — PrintLogEntry stores duration_seconds directly so we
     # can sum it server-side. Rows missing duration fall back to the slicer
     # estimate from the archive (joined for that case only).
@@ -990,6 +1003,7 @@ async def get_archive_stats(
         total_prints=total_prints,
         successful_prints=successful_prints,
         failed_prints=failed_prints,
+        cancelled_prints=cancelled_prints,
         total_print_time_hours=round(total_time, 1),
         total_filament_grams=round(total_filament, 1),
         total_cost=round(total_cost, 2),

+ 6 - 2
backend/app/api/routes/camera.py

@@ -29,6 +29,7 @@ from backend.app.services.camera import (
     get_ffmpeg_path,
     is_chamber_image_model,
     read_next_chamber_frame,
+    rtsp_socket_timeout_flag,
     test_camera_connection,
 )
 from backend.app.services.camera_fanout import (
@@ -348,8 +349,11 @@ async def generate_rtsp_mjpeg_stream(
         "tcp",
         "-rtsp_flags",
         "prefer_tcp",
-        "-timeout",
-        "30000000",  # 30 seconds in microseconds
+        # Socket I/O timeout name varies by ffmpeg version (#1504); see
+        # rtsp_socket_timeout_flag(). The 30s value is microseconds for
+        # both names.
+        f"-{rtsp_socket_timeout_flag()}",
+        "30000000",
         "-buffer_size",
         "1024000",  # 1MB buffer
         "-max_delay",

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

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

+ 4 - 0
backend/app/schemas/archive.py

@@ -147,6 +147,10 @@ class ArchiveStats(BaseModel):
     total_prints: int
     successful_prints: int
     failed_prints: int
+    # User/system-stopped prints (PrintLogEntry.status in stopped/cancelled/
+    # skipped). Defaulted so older clients that don't send this field still
+    # validate against historical fixtures.
+    cancelled_prints: int = 0
     total_print_time_hours: float
     total_filament_grams: float
     total_cost: float

+ 67 - 0
backend/app/services/camera.py

@@ -11,6 +11,7 @@ import os
 import shutil
 import ssl
 import struct
+import subprocess
 import uuid
 from datetime import datetime
 from pathlib import Path
@@ -24,6 +25,9 @@ JPEG_END = b"\xff\xd9"
 # Cache the ffmpeg path after first lookup
 _ffmpeg_path: str | None = None
 
+# Cached result of rtsp_socket_timeout_flag(); see that function for context.
+_rtsp_socket_timeout_flag: str | None = None
+
 # Track PIDs of ffmpeg processes spawned for one-shot frame capture (snapshot).
 # The cleanup task in routes/camera.py checks this set to avoid killing active captures.
 _active_capture_pids: set[int] = set()
@@ -66,6 +70,69 @@ def get_ffmpeg_path() -> str | None:
     return ffmpeg_path
 
 
+def rtsp_socket_timeout_flag() -> str:
+    """Return the ffmpeg argv flag (without the leading dash) that sets the
+    RTSP demuxer's client-side TCP socket I/O timeout, in microseconds.
+
+    ffmpeg has shipped three different option arrangements for this over
+    time, and Bambuddy supports the full range:
+
+    - **Modern ffmpeg (5.x / 6.x / 7.x)** — Debian 13, Ubuntu 24.04, current
+      Homebrew, etc. ``-timeout`` is the socket I/O timeout (microseconds);
+      ``-stimeout`` was REMOVED.
+    - **Transitional ffmpeg (~late-4.x, some 5.x builds)** — Ubuntu 22.04's
+      shipped version is one of these. ``-timeout`` was deprecated and
+      *repurposed* to mean the RTSP listen-mode incoming-connection
+      timeout — and any non-zero value implies ``-listen``, which makes
+      ffmpeg bind the localhost proxy port and fail with EADDRINUSE
+      (#1504). ``-stimeout`` was the replacement socket I/O timeout in
+      that window.
+    - **Old ffmpeg (early 4.x and earlier)** — ``-timeout`` is socket I/O
+      timeout (the original meaning, before the deprecation churn).
+
+    We probe ``-h demuxer=rtsp`` once and cache: if ``-stimeout`` is
+    advertised, prefer it (covers the transitional window and stays
+    correct on the older builds that still accept it as an alias); else
+    fall back to ``-timeout`` (correct on modern and pre-deprecation
+    ffmpeg). The result is cached for the process lifetime — ffmpeg
+    isn't going to swap mid-run.
+
+    Returns the option name without the leading dash, e.g. ``"timeout"``
+    or ``"stimeout"``. Callers must prepend ``-`` themselves so a string
+    formatting bug can't pass an empty flag.
+    """
+    global _rtsp_socket_timeout_flag
+
+    if _rtsp_socket_timeout_flag is not None:
+        return _rtsp_socket_timeout_flag
+
+    ffmpeg = get_ffmpeg_path()
+    chosen = "timeout"  # safe default for modern ffmpeg
+    if ffmpeg:
+        try:
+            result = subprocess.run(
+                [ffmpeg, "-hide_banner", "-h", "demuxer=rtsp"],
+                capture_output=True,
+                text=True,
+                timeout=5,
+                check=False,
+            )
+            help_text = (result.stdout or "") + (result.stderr or "")
+            # Help lines list each option as `-<name> ` (trailing space) — match
+            # that exact form so we don't accidentally hit a substring elsewhere.
+            if "-stimeout " in help_text:
+                chosen = "stimeout"
+        except (OSError, subprocess.SubprocessError) as exc:
+            # If probing fails, keep the modern-ffmpeg default. Worst case
+            # is the EADDRINUSE regression returns for transitional-ffmpeg
+            # users — same as before this function existed.
+            logger.warning("Could not probe ffmpeg RTSP timeout flag, defaulting to -timeout: %s", exc)
+
+    _rtsp_socket_timeout_flag = chosen
+    logger.info("RTSP socket I/O timeout flag: -%s", chosen)
+    return chosen
+
+
 def supports_rtsp(model: str | None) -> bool:
     """Check if printer model supports RTSP camera streaming.
 

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

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

+ 5 - 1
backend/app/services/external_camera.py

@@ -683,6 +683,8 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
         logger.error("ffmpeg not found - required for RTSP streaming")
         return
 
+    from backend.app.services.camera import rtsp_socket_timeout_flag
+
     # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
     proxy_server = None
     effective_url = url
@@ -715,7 +717,9 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
         "tcp",
         "-rtsp_flags",
         "prefer_tcp",
-        "-timeout",
+        # Socket I/O timeout name varies by ffmpeg version (#1504); see
+        # `rtsp_socket_timeout_flag()` in services.camera.
+        f"-{rtsp_socket_timeout_flag()}",
         "30000000",
         "-buffer_size",
         "1024000",

+ 19 - 2
backend/app/services/failure_analysis.py

@@ -75,6 +75,11 @@ class FailureAnalysisService:
         total_result = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*base_filter)))
         total_prints = total_result.scalar() or 0
 
+        successful_result = await self.db.execute(
+            select(func.count(PrintLogEntry.id)).where(and_(*base_filter, PrintLogEntry.status == "completed"))
+        )
+        successful_prints = successful_result.scalar() or 0
+
         failed_result = await self.db.execute(
             select(func.count(PrintLogEntry.id)).where(
                 and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
@@ -82,7 +87,14 @@ class FailureAnalysisService:
         )
         failed_prints = failed_result.scalar() or 0
 
-        failure_rate = (failed_prints / total_prints * 100) if total_prints > 0 else 0
+        # Failure rate divides by quality-outcome prints only — a cancelled or
+        # skipped print is neither a success nor a failure of the printer, so
+        # including it in the denominator silently lowered the displayed rate
+        # whenever the user stopped jobs (#1390). Total Prints (the absolute
+        # count incl. cancelled) is still returned separately for the "X / Y
+        # prints failed" caption.
+        outcome_prints = successful_prints + failed_prints
+        failure_rate = (failed_prints / outcome_prints * 100) if outcome_prints > 0 else 0
 
         # Failures by reason
         reason_result = await self.db.execute(
@@ -188,6 +200,9 @@ class FailureAnalysisService:
             ]
 
             week_total = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*week_filter)))
+            week_successful = await self.db.execute(
+                select(func.count(PrintLogEntry.id)).where(and_(*week_filter, PrintLogEntry.status == "completed"))
+            )
             week_failed = await self.db.execute(
                 select(func.count(PrintLogEntry.id)).where(
                     and_(*week_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
@@ -195,8 +210,10 @@ class FailureAnalysisService:
             )
 
             total = week_total.scalar() or 0
+            successful = week_successful.scalar() or 0
             failed = week_failed.scalar() or 0
-            rate = (failed / total * 100) if total > 0 else 0
+            week_outcome = successful + failed
+            rate = (failed / week_outcome * 100) if week_outcome > 0 else 0
 
             trend_data.append(
                 {

+ 169 - 5
backend/app/services/print_scheduler.py

@@ -9,6 +9,7 @@ from pathlib import Path
 
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 from backend.app.core.config import settings
 from backend.app.core.database import async_session, run_with_retry
@@ -18,6 +19,8 @@ from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.bambu_ftp import (
     cache_3mf_download,
     delete_file_async,
@@ -855,8 +858,19 @@ class PrintScheduler:
         # Check if user prefers lowest remaining filament when multiple spools match
         prefer_lowest = await self._get_bool_setting(db, "prefer_lowest_filament")
 
+        # When the preference is on, surface Bambuddy's inventory-side
+        # remaining for each slot that's bound to a tracked spool, so the
+        # sort beats the MQTT-only blind spot (#1508). Skip the lookup
+        # entirely when the preference is off — no behaviour change for
+        # users who haven't opted in.
+        inventory_remain_overrides: dict[int, float] | None = None
+        if prefer_lowest:
+            inventory_remain_overrides = await self._build_inventory_remain_overrides(db, printer_id, loaded_filaments)
+
         # Compute mapping: match required filaments to available slots
-        return self._match_filaments_to_slots(filament_reqs, loaded_filaments, prefer_lowest)
+        return self._match_filaments_to_slots(
+            filament_reqs, loaded_filaments, prefer_lowest, inventory_remain_overrides
+        )
 
     def _build_override_direct_mapping(self, force_overrides: list[dict], status) -> list[int] | None:
         """Build an AMS mapping directly from force-color overrides without a 3MF.
@@ -1015,8 +1029,156 @@ class PrintScheduler:
         except ValueError:
             return False
 
+    async def _build_inventory_remain_overrides(
+        self, db: AsyncSession, printer_id: int, loaded: list[dict]
+    ) -> dict[int, float]:
+        """Return ``{global_tray_id: remaining_grams}`` for AMS slots the user
+        has bound to an inventory spool — Bambuddy-side or Spoolman-side.
+
+        The MQTT ``remain`` field on a tray is the printer firmware's
+        RFID-decremented value, which has two limitations the "Prefer Lowest
+        Remaining Filament" feature has been ignoring (#1508):
+
+        - it's only meaningful for Bambu RFID spools; everything else reports
+          ``-1`` (then clamped to a sentinel), so multiple non-RFID trays
+          compare equal and the sort collapses to AMS-slot order — the user
+          who's curating inventory weights gets the lower-slot pick instead
+          of the lower-remaining pick;
+        - 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.
+
+        When the user has bound a spool to a slot, their own inventory
+        tracking is authoritative; this helper surfaces that value so the
+        sort can prefer it. Slots without a binding are absent from the
+        returned map — the caller then falls back to MQTT ``remain`` for
+        those, preserving the pre-#1508 behaviour for un-tracked spools.
+
+        Returns an empty map on any failure (no inventory bindings, DB
+        error, Spoolman unreachable). A best-effort lookup; "Prefer Lowest"
+        is a preference, not a guarantee.
+        """
+        if not loaded:
+            return {}
+        # External / virtual-tray slots are tracked separately from AMS — skip
+        # them so a VT-loaded spool doesn't accidentally inherit a tracked
+        # AMS binding (the tables use ams_id 254/255 for VT, but the cross
+        # match is fiddly and out of scope for this fix).
+        tracked_slots = [(f["ams_id"], f["tray_id"], f["global_tray_id"]) for f in loaded if not f.get("is_external")]
+        if not tracked_slots:
+            return {}
+
+        is_spoolman = await self._is_spoolman_mode(db)
+        overrides: dict[int, float] = {}
+
+        if is_spoolman:
+            result = await db.execute(
+                select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
+            )
+            assignments = list(result.scalars().all())
+            by_slot = {(a.ams_id, a.tray_id): a.spoolman_spool_id for a in assignments}
+            from backend.app.services.filament_deficit import _spoolman_remaining_grams
+
+            for ams_id, tray_id, gtid in tracked_slots:
+                spoolman_id = by_slot.get((ams_id, tray_id))
+                if spoolman_id is None:
+                    continue
+                grams = await _spoolman_remaining_grams(spoolman_id)
+                if grams is not None:
+                    overrides[gtid] = grams
+            return overrides
+
+        # Internal inventory mode (default). selectinload matches the pattern
+        # used elsewhere (inventory.py, spoolman.py routes) — a single query
+        # plus an eager-loaded relationship rather than an explicit join, so
+        # the row-attribute shape is exactly what those routes already rely on.
+        result = await db.execute(
+            select(SpoolAssignment)
+            .options(selectinload(SpoolAssignment.spool))
+            .where(SpoolAssignment.printer_id == printer_id)
+        )
+        assignments = list(result.scalars().all())
+        by_slot = {(a.ams_id, a.tray_id): a.spool for a in assignments}
+        for ams_id, tray_id, gtid in tracked_slots:
+            spool = by_slot.get((ams_id, tray_id))
+            if spool is None:
+                continue
+            label = float(spool.label_weight or 0)
+            used = float(spool.weight_used or 0)
+            overrides[gtid] = max(0.0, label - used)
+        return overrides
+
+    @staticmethod
+    async def _is_spoolman_mode(db: AsyncSession) -> bool:
+        """Mirror of ``filament_deficit._is_spoolman_mode`` — kept private
+        here to avoid making this module import-dependent on that private
+        helper's signature."""
+        try:
+            from backend.app.api.routes.settings import get_setting
+
+            v = await get_setting(db, "spoolman_enabled")
+            return bool(v) and v.lower() == "true"
+        except Exception:
+            return False
+
+    @staticmethod
+    def _slot_priority(ams_id: int | None, tray_id: int | None) -> int:
+        """Deterministic slot-position tie-breaker for the prefer-lowest sort.
+
+        Three bands, matched to the emission order in ``_build_loaded_filaments``
+        so a tied sort produces the same physical-position order the pre-#1508
+        stable sort did (preserves the regression-free baseline):
+
+        - Regular AMS (``ams_id`` 0..7): ``ams_id * 4 + tray_id`` → 0..31
+        - AMS-HT (``ams_id`` >= 128, single tray): ``1000 + (ams_id - 128) * 4``
+        - External / VT (``ams_id`` < 0, or ``None``): ``10_000``
+
+        Banding ensures regular AMS < AMS-HT < external on ties, regardless of
+        what the raw ``ams_id`` happens to be (in particular, ``ams_id = -1``
+        for VT must NOT sort to a negative number or it would beat AMS slot 0).
+        """
+        if ams_id is None or ams_id < 0:
+            return 10_000
+        if ams_id >= 128:
+            return 1_000 + (ams_id - 128) * 4 + (tray_id or 0)
+        return ams_id * 4 + (tray_id or 0)
+
+    @staticmethod
+    def _prefer_lowest_sort_key(f: dict, overrides: dict[int, float] | None) -> tuple[int, float, int]:
+        """Sort key for the "Prefer Lowest Remaining Filament" preference.
+
+        Two-tier ordering: inventory-tracked spools always sort BEFORE
+        non-tracked spools (the user has told us they care about these
+        specifically), then ascending by remaining within each tier, then
+        ascending by AMS slot position as the deterministic tie-breaker.
+
+        Tiers are flagged by the first tuple element (0 = inventory-tracked,
+        1 = MQTT-only / unknown). Cross-tier value comparisons never run
+        because the tier flag dominates — which is what lets us mix grams
+        (inventory) and percent (MQTT) without a unit conversion.
+
+        Within the MQTT tier ``remain = -1`` (unknown) is mapped to 101 so
+        spools the printer DOES know something about sort ahead of those
+        it knows nothing about — preserves pre-#1508 behaviour for the
+        no-inventory-binding case.
+
+        Slot tie-breaker via ``_slot_priority`` so regular AMS < AMS-HT <
+        external on ties, matching the legacy emission-order stable sort.
+        """
+        gtid = f.get("global_tray_id")
+        slot_order = PrintScheduler._slot_priority(f.get("ams_id"), f.get("tray_id"))
+        if overrides and gtid in overrides:
+            return (0, overrides[gtid], slot_order)
+        remain = f.get("remain", -1)
+        return (1, float(remain) if remain is not None and remain >= 0 else 101.0, slot_order)
+
     def _match_filaments_to_slots(
-        self, required: list[dict], loaded: list[dict], prefer_lowest: bool = False
+        self,
+        required: list[dict],
+        loaded: list[dict],
+        prefer_lowest: bool = False,
+        inventory_remain_overrides: dict[int, float] | None = None,
     ) -> list[int] | None:
         """Match required filaments to loaded filaments and build AMS mapping.
 
@@ -1063,9 +1225,11 @@ class PrintScheduler:
             if req_nozzle_id is not None:
                 available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
 
-            # Sort by remaining filament (ascending) so lowest-remain spool wins .find()
+            # Sort by remaining filament (ascending) so lowest-remain spool wins .find().
+            # Inventory-tracked spools sort before MQTT-only ones (#1508); see
+            # _prefer_lowest_sort_key for the full rationale.
             if prefer_lowest:
-                available.sort(key=lambda f: f.get("remain", -1) if f.get("remain", -1) >= 0 else 101)
+                available.sort(key=lambda f: self._prefer_lowest_sort_key(f, inventory_remain_overrides))
 
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
@@ -1084,7 +1248,7 @@ class PrintScheduler:
                         f"using color matching among trays: {[f['global_tray_id'] for f in idx_matches]}"
                     )
                     if prefer_lowest:
-                        idx_matches.sort(key=lambda f: f.get("remain", -1) if f.get("remain", -1) >= 0 else 101)
+                        idx_matches.sort(key=lambda f: self._prefer_lowest_sort_key(f, inventory_remain_overrides))
                     # Use color matching within this subset
                     for f in idx_matches:
                         f_color = f.get("color", "")

+ 1 - 1
backend/tests/unit/services/test_filament_deficit.py

@@ -223,7 +223,7 @@ class TestFilamentDeficit:
         printer = await printer_factory()
         archive = PrintArchive(
             filename="ghost.3mf",
-            file_path="/tmp/nope-does-not-exist.3mf",
+            file_path="/nonexistent/ghost.3mf",
             file_size=0,
             status="completed",
         )

+ 19 - 4
backend/tests/unit/test_code_quality.py

@@ -218,9 +218,27 @@ class TestModuleImports:
         """Verify all Python modules can be imported without errors.
 
         This catches syntax errors and missing dependencies.
+
+        IMPORTANT: We must NOT ``del sys.modules[name]`` to force a fresh
+        import here. ``backend.app.main`` is a stateful module — re-importing
+        it builds NEW module-level dicts (_timelapse_baselines,
+        _expected_prints, _active_prints, …) and re-runs ``root_logger.
+        addHandler(console_handler)``. Any test that already bound those
+        names via ``from backend.app.main import _timelapse_baselines`` now
+        holds a stale reference, while production code resolves the symbol
+        through the new module instance — they're two different dicts. CI
+        under -n 2 puts test_code_quality.py on the same worker as
+        test_print_start_assigns_printer_id_to_vp_archive.py and
+        test_timelapse_baseline_restart_recovery.py, and those tests see
+        their mock_archive un-mutated / their baseline dict empty even
+        though production logged the mutations went through. Local -n 30
+        spreads the tests across workers and the collision never happens.
+
+        ``importlib.import_module`` already covers the "is this importable"
+        check — it returns the cached module if cached, or runs the import
+        machinery if not. Either way, an import-time error surfaces here.
         """
         import importlib
-        import sys
 
         # Modules to test importing
         modules = [
@@ -235,9 +253,6 @@ class TestModuleImports:
         errors = []
         for module_name in modules:
             try:
-                # Remove from cache first to ensure fresh import
-                if module_name in sys.modules:
-                    del sys.modules[module_name]
                 importlib.import_module(module_name)
             except Exception as e:
                 errors.append(f"{module_name}: {type(e).__name__}: {e}")

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

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

+ 139 - 0
backend/tests/unit/test_ffmpeg_rtsp_timeout_flag.py

@@ -0,0 +1,139 @@
+"""Regression for #1504: ffmpeg RTSP socket-I/O timeout flag.
+
+The RTSP demuxer's client-side socket I/O timeout option name varies by
+ffmpeg version (full chronology in
+`backend/app/services/camera.rtsp_socket_timeout_flag`). Hard-coding
+either ``-timeout`` or ``-stimeout`` regresses one half of the install
+base. The flag is therefore probed at runtime; this module tests that
+probe and guards against either RTSP ffmpeg argv re-hard-coding the
+wrong literal.
+"""
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+import backend.app.services.camera as camera_svc
+from backend.app.services.camera import rtsp_socket_timeout_flag
+
+
+@pytest.fixture(autouse=True)
+def _reset_cache():
+    """The probe caches its result in a module-level global. Reset it
+    before every test so each one sees a fresh probe."""
+    camera_svc._rtsp_socket_timeout_flag = None
+    yield
+    camera_svc._rtsp_socket_timeout_flag = None
+
+
+class TestRtspSocketTimeoutFlagProbe:
+    def test_prefers_stimeout_when_ffmpeg_advertises_it(self):
+        """Transitional ffmpeg (~late-4.x): both options are listed and
+        ``-timeout`` is the broken listen-mode option — pick ``-stimeout``."""
+        transitional_help = (
+            "  -listen_timeout    <int>  ... incoming connections ...\n"
+            "  -stimeout          <int64> ... socket TCP I/O ...\n"
+            "  -timeout           <int>  ... DEPRECATED ...\n"
+        )
+        with (
+            patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.services.camera.subprocess.run") as mock_run,
+        ):
+            mock_run.return_value.stdout = transitional_help
+            mock_run.return_value.stderr = ""
+            assert rtsp_socket_timeout_flag() == "stimeout"
+
+    def test_falls_back_to_timeout_on_modern_ffmpeg(self):
+        """Modern ffmpeg (5+/6+/7+): ``-stimeout`` no longer exists and
+        ``-timeout`` is back to meaning socket I/O — pick ``-timeout``."""
+        modern_help = (
+            "  -listen_timeout    <int>  ... incoming connections ...\n"
+            "  -timeout           <int64> ... socket I/O ...\n"
+            "  -reorder_queue_size <int> ... reordered packets ...\n"
+        )
+        with (
+            patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.services.camera.subprocess.run") as mock_run,
+        ):
+            mock_run.return_value.stdout = modern_help
+            mock_run.return_value.stderr = ""
+            assert rtsp_socket_timeout_flag() == "timeout"
+
+    def test_defaults_to_timeout_when_ffmpeg_missing(self):
+        """No ffmpeg available — return the modern default so we don't
+        wedge ffmpeg-less unit tests trying to import camera.py."""
+        with patch.object(camera_svc, "get_ffmpeg_path", return_value=None):
+            assert rtsp_socket_timeout_flag() == "timeout"
+
+    def test_defaults_to_timeout_when_probe_raises(self):
+        """If subprocess probe blows up, prefer the modern default —
+        breaking the transitional-ffmpeg case is preferable to crashing
+        every live-view start."""
+        with (
+            patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.services.camera.subprocess.run", side_effect=OSError("boom")),
+        ):
+            assert rtsp_socket_timeout_flag() == "timeout"
+
+    def test_result_is_cached_across_calls(self):
+        """Probing ffmpeg is a subprocess spawn; cache it for the
+        process lifetime (ffmpeg won't swap mid-run)."""
+        with (
+            patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.services.camera.subprocess.run") as mock_run,
+        ):
+            mock_run.return_value.stdout = "  -timeout  <int64>\n"
+            mock_run.return_value.stderr = ""
+            rtsp_socket_timeout_flag()
+            rtsp_socket_timeout_flag()
+            rtsp_socket_timeout_flag()
+            assert mock_run.call_count == 1
+
+    def test_substring_match_does_not_false_positive(self):
+        """Match the option as ``-stimeout `` (trailing space) so an
+        unrelated mention like ``-listen_timeout`` or a fragment in
+        another section doesn't trick us into picking the missing flag."""
+        only_listen_help = (
+            "  -listen_timeout    <int>  ... incoming connections ...\n"
+            "  -timeout           <int64> ... socket I/O ...\n"
+        )
+        with (
+            patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
+            patch("backend.app.services.camera.subprocess.run") as mock_run,
+        ):
+            mock_run.return_value.stdout = only_listen_help
+            mock_run.return_value.stderr = ""
+            assert rtsp_socket_timeout_flag() == "timeout"
+
+
+class TestRtspArgvUsesProbe:
+    """The two RTSP ffmpeg callers must not hard-code either flag literal —
+    they must consume the probe so version-dependent correctness is
+    preserved. Guards #1504 from being half-fixed again."""
+
+    # Anchor on this file so the assertion is CWD-independent (pytest can
+    # be invoked from the project root OR from backend/, depending on who
+    # runs it). __file__ lives at backend/tests/unit/, so the repo root
+    # is three parents up.
+    _REPO_ROOT = Path(__file__).resolve().parents[3]
+    _RTSP_FFMPEG_CALLERS = (
+        "backend/app/api/routes/camera.py",
+        "backend/app/services/external_camera.py",
+    )
+
+    @pytest.mark.parametrize("rel", _RTSP_FFMPEG_CALLERS)
+    def test_no_hard_coded_timeout_literal(self, rel):
+        """Neither RTSP ffmpeg argv may pass a hard-coded ``-timeout``
+        or ``-stimeout`` literal — both must come from the probe."""
+        src = (self._REPO_ROOT / rel).read_text()
+        assert '"-timeout"' not in src, (
+            f"{rel} hard-codes `-timeout` — this is the listen-mode option on "
+            f"transitional ffmpeg (EADDRINUSE, #1504). Use rtsp_socket_timeout_flag()."
+        )
+        assert '"-stimeout"' not in src, (
+            f"{rel} hard-codes `-stimeout` — this option was removed in ffmpeg 7. Use rtsp_socket_timeout_flag()."
+        )
+        assert "rtsp_socket_timeout_flag()" in src, (
+            f"{rel} should derive its RTSP socket timeout flag from rtsp_socket_timeout_flag() — see #1504."
+        )

+ 264 - 0
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -505,6 +505,270 @@ class TestPreferLowestFilament:
         assert result == [254]  # Should pick external spool (10%) over AMS (80%)
 
 
+class TestPreferLowestInventoryOverride:
+    """Tests for the #1508 inventory-aware sort: when the user has bound a
+    Bambuddy inventory spool to an AMS slot, that spool's remaining weight
+    becomes the sort signal instead of the MQTT ``remain`` percentage.
+
+    The fix is two-tier: inventory-tracked spools always sort before
+    MQTT-only ones, then ascending by remaining within each tier, then
+    ascending by AMS slot position. See
+    ``print_scheduler._prefer_lowest_sort_key`` for the rationale.
+    """
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_inventory_override_beats_mqtt_remain(self, scheduler):
+        """Slot 4's inventory shows 50 g remaining; slot 1's clone has 950 g.
+        MQTT ``remain`` is -1 for both (non-RFID spools), so without the
+        override the sort collapses to AMS-slot order and slot 1 wins.
+        With the override slot 4 (the original, nearly empty) wins. This is
+        the literal reporter scenario in #1508.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": -1,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 3,
+                "global_tray_id": 3,
+                "remain": -1,
+            },
+        ]
+        # Slot 1 (gtid 0) is the fresh clone at 950 g; slot 4 (gtid 3) is the
+        # nearly-empty original at 50 g.
+        overrides = {0: 950.0, 3: 50.0}
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides
+        )
+        assert result == [3]
+
+    def test_inventory_override_with_zero_grams_still_wins(self, scheduler):
+        """An inventory-tracked spool at 0 g must still sort first within
+        its tier — the user wants to finish what's left (or be told there's
+        a deficit) rather than skip to the fresh one. The clamp-to-101
+        legacy logic only fires on negative values, so 0 g stays 0.0.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": -1,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 1,
+                "global_tray_id": 1,
+                "remain": -1,
+            },
+        ]
+        overrides = {0: 500.0, 1: 0.0}
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides
+        )
+        assert result == [1]
+
+    def test_inventory_tier_beats_mqtt_tier_regardless_of_value(self, scheduler):
+        """Mixed mode: one slot is inventory-tracked at 800 g (high), the
+        other has only a MQTT remain of 10 (very low). The inventory tier
+        wins because the user has explicitly told us to manage that slot —
+        unit-mixing across tiers is intentional and resolved by the tier
+        flag, not value comparison.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": 10,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 1,
+                "global_tray_id": 1,
+                "remain": -1,
+            },
+        ]
+        overrides = {1: 800.0}  # only slot 2 has an inventory binding
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides
+        )
+        assert result == [1]
+
+    def test_two_tracked_spools_tied_at_same_grams_lower_slot_wins(self, scheduler):
+        """Two inventory-tracked spools with genuinely equal remaining
+        weight — slot tie-breaker decides. Lower AMS slot wins to match
+        the user's mental model of "use the lower slot first when equal."
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 1,
+                "global_tray_id": 1,
+                "remain": -1,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": -1,
+            },
+        ]
+        overrides = {0: 500.0, 1: 500.0}
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides
+        )
+        assert result == [0]
+
+    def test_no_override_falls_back_to_mqtt_remain(self, scheduler):
+        """When no slots are inventory-bound the override map is empty
+        and behaviour is identical to the pre-#1508 MQTT-only sort.
+        Regression guard for the un-tracked-spool case.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": 80,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 1,
+                "global_tray_id": 1,
+                "remain": 30,
+            },
+        ]
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides={}
+        )
+        assert result == [1]
+
+    def test_no_override_unknown_remain_sorts_after_known(self, scheduler):
+        """In the no-binding case, ``remain = -1`` (non-RFID, unknown) must
+        still sort *after* a slot the printer knows something about — the
+        legacy sentinel-101 behaviour is preserved.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": -1,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 1,
+                "global_tray_id": 1,
+                "remain": 50,
+            },
+        ]
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=None
+        )
+        assert result == [1]
+
+    def test_external_with_negative_ams_id_does_not_outrank_ams_on_tie(self, scheduler):
+        """``_build_loaded_filaments`` emits external/VT trays with
+        ``ams_id = -1``. A naive ``ams_id * 4 + tray_id`` slot-priority
+        formula would compute -4 for an external and 0 for AMS slot 0 —
+        flipping the legacy stable-sort baseline (which kept AMS first
+        because it's emitted before externals). When ``remain`` ties
+        between the two, AMS slot 0 must still win.
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": -1,
+                "is_external": False,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": -1,
+                "tray_id": 0,
+                "global_tray_id": 254,
+                "remain": -1,
+                "is_external": True,
+            },
+        ]
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=None
+        )
+        assert result == [0]
+
+    def test_ams_ht_does_not_outrank_regular_ams_on_tie(self, scheduler):
+        """AMS-HT units use ``ams_id`` >= 128 with a single tray. On a tied
+        ``remain`` value, regular AMS slot 0 (slot_priority 0) must beat
+        AMS-HT (slot_priority 1000+).
+        """
+        required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
+        loaded = [
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 0,
+                "tray_id": 0,
+                "global_tray_id": 0,
+                "remain": 50,
+            },
+            {
+                "type": "PLA",
+                "color": "#FF0000",
+                "ams_id": 128,
+                "tray_id": 0,
+                "global_tray_id": 128,
+                "remain": 50,
+            },
+        ]
+        result = scheduler._match_filaments_to_slots(
+            required, loaded, prefer_lowest=True, inventory_remain_overrides=None
+        )
+        assert result == [0]
+
+
 class TestBuildLoadedFilamentsTrayInfoIdx:
     """Test tray_info_idx extraction in _build_loaded_filaments."""
 

+ 194 - 0
backend/tests/unit/test_scheduler_inventory_remain.py

@@ -0,0 +1,194 @@
+"""Tests for the inventory-remain override builder in print_scheduler (#1508).
+
+The MQTT ``remain`` field on an AMS tray is the printer firmware's
+RFID-tracked value, which is ``-1`` for non-Bambu spools (and even when
+set diverges from Bambuddy's inventory). When the user has bound an
+inventory spool to an AMS slot, that inventory record's
+``label_weight - weight_used`` (or Spoolman's ``remaining_weight``) is
+the authoritative remaining-weight signal. These tests verify
+``_build_inventory_remain_overrides`` surfaces those values keyed by
+``global_tray_id`` so the "Prefer Lowest Remaining Filament" sort can
+consume them.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+def scheduler():
+    return PrintScheduler()
+
+
+def _make_async_session_returning(rows: list):
+    """Build a stub AsyncSession whose .execute() returns an object whose
+    .all() (and .scalars().all()) yield ``rows``."""
+    result = MagicMock()
+    result.all.return_value = rows
+    scalars = MagicMock()
+    scalars.all.return_value = rows
+    result.scalars.return_value = scalars
+    db = MagicMock()
+    db.execute = AsyncMock(return_value=result)
+    return db
+
+
+class TestInternalInventoryOverrides:
+    @pytest.mark.asyncio
+    async def test_returns_remaining_grams_for_bound_slots(self, scheduler):
+        """Two slots bound; both come back keyed by global_tray_id with the
+        correct ``label_weight - weight_used`` in grams. This is the
+        reporter scenario in #1508: slot 1 has a 950 g clone, slot 4 has
+        a 50 g original — the sort can now actually pick the 50 g spool.
+
+        The override builder uses ``select(SpoolAssignment).options(
+        selectinload(SpoolAssignment.spool))`` (matching the rest of the
+        codebase), so the rows it iterates expose ``.ams_id``, ``.tray_id``
+        and ``.spool`` directly — the test stubs the same shape.
+        """
+        spool_a = SimpleNamespace(label_weight=1000, weight_used=50)  # 950 g remaining
+        spool_b = SimpleNamespace(label_weight=1000, weight_used=950)  # 50 g remaining
+        rows = [
+            SimpleNamespace(ams_id=0, tray_id=0, spool=spool_a),
+            SimpleNamespace(ams_id=0, tray_id=3, spool=spool_b),
+        ]
+        loaded = [
+            {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
+            {"ams_id": 0, "tray_id": 3, "global_tray_id": 3, "is_external": False},
+        ]
+        db = _make_async_session_returning(rows)
+        with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        assert out == {0: 950.0, 3: 50.0}
+
+    @pytest.mark.asyncio
+    async def test_skips_external_slots(self, scheduler):
+        """VT / external slots are tracked separately from AMS inventory
+        bindings — the override builder must not assign them an inventory
+        remaining value even if (somehow) an assignment row exists.
+        """
+        loaded = [
+            {"ams_id": -1, "tray_id": 0, "global_tray_id": 254, "is_external": True},
+        ]
+        db = _make_async_session_returning([])
+        with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        # DB shouldn't even be queried — nothing AMS-side to look up.
+        db.execute.assert_not_called()
+        assert out == {}
+
+    @pytest.mark.asyncio
+    async def test_empty_loaded_returns_empty(self, scheduler):
+        """No loaded filaments → no overrides. The scheduler short-circuits
+        before this is called in practice, but the function must be
+        defensive — it's used in any prefer_lowest dispatch path."""
+        db = _make_async_session_returning([])
+        with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=[])
+        assert out == {}
+        db.execute.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_negative_remaining_clamped_to_zero(self, scheduler):
+        """An over-consumed spool (weight_used > label_weight) shouldn't
+        produce a negative grams value — clamped to 0 so the sort treats
+        it as fully empty rather than "more empty than zero."
+        """
+        spool = SimpleNamespace(label_weight=1000, weight_used=1100)
+        rows = [
+            SimpleNamespace(ams_id=0, tray_id=0, spool=spool),
+        ]
+        loaded = [{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False}]
+        db = _make_async_session_returning(rows)
+        with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        assert out == {0: 0.0}
+
+    @pytest.mark.asyncio
+    async def test_slot_without_binding_absent_from_overrides(self, scheduler):
+        """A slot that has loaded filament but no inventory binding must
+        not appear in the override map — the sort then falls back to MQTT
+        ``remain`` for that one slot, preserving pre-#1508 behaviour.
+        """
+        rows = [
+            SimpleNamespace(
+                ams_id=0,
+                tray_id=0,
+                spool=SimpleNamespace(label_weight=1000, weight_used=100),
+            ),
+        ]
+        loaded = [
+            {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
+            {"ams_id": 0, "tray_id": 1, "global_tray_id": 1, "is_external": False},
+        ]
+        db = _make_async_session_returning(rows)
+        with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        assert out == {0: 900.0}
+        assert 1 not in out
+
+
+class TestSpoolmanModeOverrides:
+    @pytest.mark.asyncio
+    async def test_spoolman_remaining_grams_used_when_available(self, scheduler):
+        """Spoolman mode: each bound slot's spoolman_spool_id is fetched
+        through ``_spoolman_remaining_grams``; the result is the same
+        global-tray-id-keyed grams map. Parity rule with internal mode
+        (feedback_inventory_modes_parity).
+        """
+        rows = [
+            SimpleNamespace(printer_id=1, ams_id=0, tray_id=0, spoolman_spool_id=42),
+            SimpleNamespace(printer_id=1, ams_id=0, tray_id=2, spoolman_spool_id=99),
+        ]
+        loaded = [
+            {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
+            {"ams_id": 0, "tray_id": 2, "global_tray_id": 2, "is_external": False},
+        ]
+        db = _make_async_session_returning(rows)
+
+        async def _fake_grams(spool_id: int):
+            return {42: 720.0, 99: 80.0}[spool_id]
+
+        with (
+            patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=True)),
+            patch(
+                "backend.app.services.filament_deficit._spoolman_remaining_grams",
+                new=AsyncMock(side_effect=_fake_grams),
+            ),
+        ):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        assert out == {0: 720.0, 2: 80.0}
+
+    @pytest.mark.asyncio
+    async def test_spoolman_unreachable_skips_silently(self, scheduler):
+        """If Spoolman is unreachable for one spool, ``_spoolman_remaining_grams``
+        returns None and that slot is omitted from the override map —
+        sorting then falls back to MQTT remain for that slot only.
+        """
+        rows = [
+            SimpleNamespace(printer_id=1, ams_id=0, tray_id=0, spoolman_spool_id=42),
+            SimpleNamespace(printer_id=1, ams_id=0, tray_id=1, spoolman_spool_id=99),
+        ]
+        loaded = [
+            {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
+            {"ams_id": 0, "tray_id": 1, "global_tray_id": 1, "is_external": False},
+        ]
+        db = _make_async_session_returning(rows)
+
+        async def _fake_grams(spool_id: int):
+            return 500.0 if spool_id == 42 else None
+
+        with (
+            patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=True)),
+            patch(
+                "backend.app.services.filament_deficit._spoolman_remaining_grams",
+                new=AsyncMock(side_effect=_fake_grams),
+            ),
+        ):
+            out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded)
+        assert out == {0: 500.0}
+        assert 1 not in out

+ 10 - 1
backend/tests/unit/test_timelapse_baseline_restart_recovery.py

@@ -78,7 +78,16 @@ async def test_running_observed_captures_baseline_on_restart_recovery():
             },
         )
 
-    assert _timelapse_baselines.get(1) == {"earlier_a.mp4", "earlier_b.mp4", "earlier_c.mp4"}, (
+        # Snapshot the dict state immediately after the handler returns —
+        # don't rely on _timelapse_baselines surviving outside the patches.
+        # CI intermittently saw the dict empty by the time a later top-level
+        # assert ran (likely an xdist-parallel teardown race on the session-
+        # scoped event_loop fixture in conftest.py). Capturing the value here
+        # is what the test actually wants to verify anyway: the handler set
+        # the baseline at the moment it returned.
+        captured = _timelapse_baselines.get(1)
+
+    assert captured == {"earlier_a.mp4", "earlier_b.mp4", "earlier_c.mp4"}, (
         "restart-recovery handler must capture the printer's existing-videos "
         "baseline so the completion-time scan can set-diff to find the new file"
     )

+ 3 - 1
docker-compose.test.yml

@@ -56,7 +56,9 @@ services:
     environment:
       - BAMBUDDY_TEST_URL=http://integration:8000
       - TESTING=1
-    command: ["pytest", "backend/tests/integration/", "-v", "--tb=short", "-p", "no:cacheprovider", "-n", "30"]
+    # -v dropped + -n auto so integration tests inherit the same noise /
+    # parallelism profile as the backend-test image (see Dockerfile.test CMD).
+    command: ["pytest", "backend/tests/integration/", "--tb=short", "-p", "no:cacheprovider", "-n", "auto"]
     volumes:
       - ./backend:/app/backend:ro
 

+ 17 - 6
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -14,6 +14,7 @@ const mockStats = {
   total_prints: 150,
   successful_prints: 140,
   failed_prints: 10,
+  cancelled_prints: 0,
   total_print_time_hours: 500.5,
   total_filament_grams: 5500,
   total_cost: 125.50,
@@ -215,11 +216,14 @@ describe('StatsPage', () => {
       });
     });
 
-    it('uses total_prints as denominator so cancelled/stopped events count (#1390)', async () => {
-      // 40 successful out of 100 total — with 20 failed and 40 cancelled/stopped
-      // mixed in. Old formula (successful / (successful + failed)) would have
-      // shown 40 / (40 + 20) = 67%. New formula shows 40 / 100 = 40%, which
-      // matches the "Total Prints: 100" the user reads right above the gauge.
+    it('excludes cancelled prints from the rate denominator and surfaces them in the breakdown (#1390)', async () => {
+      // 40 completed, 20 failed (printer-detected), 40 user-cancelled out of
+      // 100 total. The earlier fix divided by total_prints and reported 40%,
+      // which conflated user intent with print quality — cancelling a roll
+      // because you changed your mind shouldn't be counted against the
+      // printer's success rate. New behaviour: gauge = 40 / (40 + 20) = 67%;
+      // cancelled count still visible in the row breakdown so the missing
+      // 40 prints don't silently disappear.
       server.use(
         http.get('/api/v1/archives/stats', () =>
           HttpResponse.json({
@@ -227,6 +231,7 @@ describe('StatsPage', () => {
             total_prints: 100,
             successful_prints: 40,
             failed_prints: 20,
+            cancelled_prints: 35,
           }),
         ),
       );
@@ -234,7 +239,13 @@ describe('StatsPage', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Success Rate')).toBeInTheDocument();
-        expect(screen.getByText('40%')).toBeInTheDocument();
+        expect(screen.getByText('67%')).toBeInTheDocument();
+        // Cancelled count surfaces in the breakdown so the missing prints
+        // aren't silently swallowed (was the original bug in #1390). Pick a
+        // value distinct from successful_prints/failed_prints to keep the
+        // getByText query unambiguous.
+        expect(screen.getByText('Cancelled:')).toBeInTheDocument();
+        expect(screen.getByText('35')).toBeInTheDocument();
       });
     });
   });

+ 1 - 0
frontend/src/api/client.ts

@@ -651,6 +651,7 @@ export interface ArchiveStats {
   total_prints: number;
   successful_prints: number;
   failed_prints: number;
+  cancelled_prints: number;
   total_print_time_hours: number;
   total_filament_grams: number;
   total_cost: number;

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

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

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Zeitgenauigkeit',
     successful: 'Erfolgreich:',
     failed: 'Fehlgeschlagen:',
+    cancelled: 'Abgebrochen:',
     perfectEstimate: '100% = perfekte Schätzung',
     noTimeAccuracyData: 'Noch keine Zeitgenauigkeitsdaten',
     noFilamentData: 'Keine Filamentdaten verfügbar',
@@ -3054,6 +3055,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 +5686,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...',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Time Accuracy',
     successful: 'Successful:',
     failed: 'Failed:',
+    cancelled: 'Cancelled:',
     perfectEstimate: '100% = perfect estimate',
     noTimeAccuracyData: 'No time accuracy data yet',
     noFilamentData: 'No filament data available',
@@ -3057,6 +3058,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 +5697,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...',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Precisión temporal',
     successful: 'Con éxito:',
     failed: 'Fallidas:',
+    cancelled: 'Canceladas:',
     perfectEstimate: '100% = estimación perfecta',
     noTimeAccuracyData: 'Aún no hay datos de precisión temporal',
     noFilamentData: 'No hay datos de filamento disponibles',
@@ -3057,6 +3058,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 +5695,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...',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Précision du temps',
     successful: 'Succès :',
     failed: 'Échecs :',
+    cancelled: 'Annulés :',
     perfectEstimate: '100% = estimation parfaite',
     noTimeAccuracyData: 'Pas encore de données de précision',
     noFilamentData: 'Aucune donnée de filament',
@@ -3043,6 +3044,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 +5676,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...',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Accuratezza tempo',
     successful: 'Riuscite:',
     failed: 'Fallite:',
+    cancelled: 'Annullate:',
     perfectEstimate: '100% = stima perfetta',
     noTimeAccuracyData: 'Nessun dato accuratezza tempo',
     noFilamentData: 'Nessun dato filamento',
@@ -3042,6 +3043,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 +5675,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...',

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

@@ -1216,6 +1216,7 @@ export default {
     timeAccuracy: '時間精度',
     successful: '成功',
     failed: '失敗',
+    cancelled: 'キャンセル',
     perfectEstimate: '100% = 完全な推定',
     noTimeAccuracyData: '時間精度データがありません',
     noFilamentData: 'フィラメントデータがありません',
@@ -3054,6 +3055,11 @@ export default {
     collectItem10: 'Pythonパッケージバージョン',
     collectItem11: 'データベース健全性チェック',
     collectItem12: 'Docker環境の詳細',
+    bundleGenerating: 'バンドルを生成中...',
+    bundleStepConnection: 'プリンターの接続確認を実行中',
+    bundleStepVirtualPrinters: '仮想プリンターのセットアップ確認を実行中',
+    bundleStepLogScan: '最近のログから既知の問題をスキャン中',
+    bundleStepBuild: 'サポートバンドル ZIP を作成中',
   },
 
   // File manager
@@ -5681,6 +5687,10 @@ export default {
     maxDuration: '{{minutes}}分後に自動停止',
     stoppingLogs: 'ログ収集・送信中...',
     submitting: 'バグレポートを送信中...',
+    submittingStepConnection: 'プリンターの接続確認を実行中',
+    submittingStepVirtualPrinters: '仮想プリンターのセットアップ確認を実行中',
+    submittingStepLogScan: '最近のログから既知の問題をスキャン中',
+    submittingStepSubmit: 'GitHub にレポートを送信中',
     submitSuccess: 'バグレポートが正常に送信されました!',
     submitFailed: 'バグレポートの送信に失敗しました',
     diagnosticChecking: 'プリンター接続を確認中...',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Precisão do Tempo',
     successful: 'Bem-sucedido:',
     failed: 'Falhou:',
+    cancelled: 'Cancelado:',
     perfectEstimate: '100% = estimativa perfeita',
     noTimeAccuracyData: 'Nenhum dado de precisão de tempo disponível',
     noFilamentData: 'Nenhum dado de filamento disponível',
@@ -3042,6 +3043,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 +5675,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...',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: '时间准确度',
     successful: '成功:',
     failed: '失败:',
+    cancelled: '已取消:',
     perfectEstimate: '100% = 完美估计',
     noTimeAccuracyData: '暂无时间准确度数据',
     noFilamentData: '暂无耗材数据',
@@ -3042,6 +3043,11 @@ export default {
     collectItem10: 'Python 包版本',
     collectItem11: '数据库健康检查',
     collectItem12: 'Docker 环境详情',
+    bundleGenerating: '正在生成包...',
+    bundleStepConnection: '正在执行打印机连接性检查',
+    bundleStepVirtualPrinters: '正在执行虚拟打印机设置检查',
+    bundleStepLogScan: '正在扫描最近的日志以查找已知问题',
+    bundleStepBuild: '正在构建支持包 ZIP 文件',
   },
 
   // File manager
@@ -5668,6 +5674,10 @@ export default {
     maxDuration: '{{minutes}}分钟后自动停止',
     stoppingLogs: '正在收集日志并提交...',
     submitting: '正在提交错误报告...',
+    submittingStepConnection: '正在执行打印机连接性检查',
+    submittingStepVirtualPrinters: '正在执行虚拟打印机设置检查',
+    submittingStepLogScan: '正在扫描最近的日志以查找已知问题',
+    submittingStepSubmit: '正在将报告提交至 GitHub',
     submitSuccess: '错误报告提交成功!',
     submitFailed: '提交错误报告失败',
     diagnosticChecking: '正在检查打印机连接...',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: '時間準確度',
     successful: '成功:',
     failed: '失敗:',
+    cancelled: '已取消:',
     perfectEstimate: '100% = 完美估計',
     noTimeAccuracyData: '尚無時間準確度資料',
     noFilamentData: '尚無耗材資料',
@@ -3042,6 +3043,11 @@ export default {
     collectItem10: 'Python 套件版本',
     collectItem11: '資料庫健康檢查',
     collectItem12: 'Docker 環境詳情',
+    bundleGenerating: '正在產生套件...',
+    bundleStepConnection: '正在執行印表機連線檢查',
+    bundleStepVirtualPrinters: '正在執行虛擬印表機設定檢查',
+    bundleStepLogScan: '正在掃描最近的日誌以尋找已知問題',
+    bundleStepBuild: '正在建立支援套件 ZIP 檔案',
   },
 
   // File manager
@@ -5668,6 +5674,10 @@ export default {
     maxDuration: '{{minutes}} 分鐘後自動停止',
     stoppingLogs: '正在收集日誌並提交...',
     submitting: '正在提交錯誤報告...',
+    submittingStepConnection: '正在執行印表機連線檢查',
+    submittingStepVirtualPrinters: '正在執行虛擬印表機設定檢查',
+    submittingStepLogScan: '正在掃描最近的日誌以尋找已知問題',
+    submittingStepSubmit: '正在將報告提交至 GitHub',
     submitSuccess: '錯誤報告提交成功!',
     submitFailed: '提交錯誤報告失敗',
     diagnosticChecking: '正在檢查印表機連線...',

+ 14 - 2
frontend/src/pages/StatsPage.tsx

@@ -6,6 +6,7 @@ import {
   Clock,
   CheckCircle,
   XCircle,
+  Ban,
   DollarSign,
   Target,
   Zap,
@@ -191,14 +192,20 @@ function SuccessRateWidget({
     total_prints: number;
     successful_prints: number;
     failed_prints: number;
+    cancelled_prints?: number;
     prints_by_printer: Record<string, number>;
   } | undefined;
   printerMap: Map<string, string>;
   size?: 1 | 2 | 4;
 }) {
   const { t } = useTranslation();
-  const successRate = stats?.total_prints
-    ? Math.round(((stats.successful_prints || 0) / stats.total_prints) * 100)
+  // Denominator is completed + failed only — a user/system-cancelled print is
+  // neither a quality success nor a quality failure, so including it would
+  // silently lower the rate whenever the user stopped a job. The cancelled
+  // count is still shown in the breakdown below so it doesn't vanish (#1390).
+  const outcomePrints = (stats?.successful_prints || 0) + (stats?.failed_prints || 0);
+  const successRate = outcomePrints
+    ? Math.round(((stats?.successful_prints || 0) / outcomePrints) * 100)
     : 0;
 
   // Scale gauge size based on widget size
@@ -245,6 +252,11 @@ function SuccessRateWidget({
             <span className="text-sm text-bambu-gray">{t('stats.failed')}</span>
             <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
           </div>
+          <div className="flex items-center gap-2">
+            <Ban className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+            <span className="text-sm text-bambu-gray">{t('stats.cancelled')}</span>
+            <span className="text-sm text-white font-medium">{stats?.cancelled_prints || 0}</span>
+          </div>
         </div>
         {/* Show per-printer breakdown when expanded */}
         {size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (

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

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

+ 4 - 0
requirements-dev.txt

@@ -4,6 +4,10 @@ pytest-asyncio>=0.23.0
 pytest-cov>=4.1.0
 pytest-xdist>=3.5.0
 pytest-timeout>=2.4.0
+# Test-suite sharding for CI matrix. Splits the test set evenly across N
+# shards via --splits/--group; falls back to test-name hashing on the first
+# run, and uses recorded durations on subsequent runs (.test_durations).
+pytest-split>=0.9.0
 httpx>=0.27.0
 ruff>=0.8.0
 pre-commit>=4.0

+ 5 - 0
requirements.txt

@@ -74,6 +74,11 @@ httpx>=0.26.0
 # would silently keep installing the vulnerable 2.6.x line.
 urllib3>=2.7.0
 
+# Transitive of fastapi. starlette 1.0.0 has PYSEC-2026-161; 1.0.1 is the
+# fixed release. fastapi's range still admits 1.0.0 so we pin the floor
+# directly to stop the resolver from picking the vulnerable build.
+starlette>=1.0.1
+
 # Plate Detection (optional - enables build plate empty detection)
 opencv-python-headless>=4.8.0
 numpy>=1.24.0

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


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


+ 2 - 2
static/index.html

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

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