15 Коміти 6591fc011f ... 1e734fb7c6

Автор SHA1 Опис Дата
  maziggy 1e734fb7c6 fix(stats): cancelled prints get their own bucket; gauge denominator excludes them (#1390 follow-up) 3 днів тому
  maziggy b9b06a7351 chore(docker): silence Trivy DS-0026 on Dockerfile.test via HEALTHCHECK NONE 3 днів тому
  maziggy 12d344cbfc ci(docker): full backend suite in Docker, 4-way matrix shard, GHA cache backend 3 днів тому
  maziggy a4afc9c073 ci(docker): stop re-running unit tests inside the test image 3 днів тому
  maziggy 0aadce1a8a ci(docker): drop -v, -n auto instead of -n 30, pip cache mount 3 днів тому
  maziggy ca08f1f340 fix(test): stop sys.modules-deleting backend.app.main in test_code_quality 3 днів тому
  maziggy 22f222e4ac fix(test): snapshot _timelapse_baselines inside the patch context to dodge CI race 3 днів тому
  maziggy eb98521e93 fix(test): use /nonexistent/ instead of /tmp/ to satisfy Bandit B108 3 днів тому
  maziggy 7fbde1ea3d test(docker): include gcode_viewer/ in the test image so the packaging assertion actually runs 3 днів тому
  maziggy a64df5a922 Updated CHANGELOG 3 днів тому
  maziggy b9d51ffd80 chore(deps): floor-pin starlette>=1.0.1 against PYSEC-2026-161 3 днів тому
  maziggy 3b9633a178 ● feat(support): include sanitized connection / VP / log-health diagnostics in support bundle and bug report (#1506 follow-up) 3 днів тому
  maziggy 17602774f3 Updated BACKERS.md 3 днів тому
  maziggy 03896d1af0 fix(scheduler): use inventory weight for "Prefer Lowest Filament" sort (#1508) 3 днів тому
  maziggy eae96da56e fix(camera): probe ffmpeg for the right RTSP socket-timeout flag (#1504) 3 днів тому
41 змінених файлів з 1701 додано та 52 видалено
  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
             --ignore-vuln CVE-2025-45768
 
 
   backend-tests:
   backend-tests:
-    name: Backend Tests
+    name: Backend Tests (shard ${{ matrix.shard }}/4)
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     if: github.event_name == 'push' || github.event.pull_request.user.login != github.repository_owner
     needs: backend-lint
     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:
     steps:
       - uses: actions/checkout@v4
       - uses: actions/checkout@v4
 
 
@@ -110,11 +117,22 @@ jobs:
           pip install -r requirements.txt
           pip install -r requirements.txt
           pip install -r requirements-dev.txt
           pip install -r requirements-dev.txt
 
 
-      - name: Run tests
+      - name: Run tests (shard ${{ matrix.shard }}/4)
         timeout-minutes: 10
         timeout-minutes: 10
         run: |
         run: |
           cd backend
           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
   # Frontend Checks
@@ -261,6 +279,56 @@ jobs:
   # Docker Tests (matches test_docker.sh)
   # 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:
   docker-test:
     name: Docker Build
     name: Docker Build
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
@@ -280,20 +348,6 @@ jobs:
       - name: Verify static files exist
       - name: Verify static files exist
         run: docker run --rm bambuddy:test test -d /app/static
         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
       # Test 4: Integration Tests
       - name: Build integration container
       - name: Build integration container
         run: docker compose -f docker-compose.test.yml build integration
         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)
 - [@grizz0blaw](https://github.com/grizz0blaw)
 - [@NoahTingey](https://github.com/NoahTingey)
 - [@NoahTingey](https://github.com/NoahTingey)
 - [@sentinel-center](https://github.com/sentinel-center)
 - [@sentinel-center](https://github.com/sentinel-center)
+- [@brianehlert](https://github.com/brianehlert)
+- [@siiruup](https://github.com/siiruup)
 ---
 ---
 
 
 ## One-time and historical supporters
 ## One-time and historical supporters

Різницю між файлами не показано, бо вона завелика
+ 2 - 0
CHANGELOG.md


+ 27 - 4
Dockerfile.test

@@ -1,4 +1,5 @@
 # Test image for running backend and frontend tests
 # Test image for running backend and frontend tests
+# syntax=docker/dockerfile:1.7
 FROM python:3.13-slim AS backend-test
 FROM python:3.13-slim AS backend-test
 
 
 WORKDIR /app
 WORKDIR /app
@@ -8,15 +9,26 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     curl \
     curl \
     && rm -rf /var/lib/apt/lists/*
     && 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.txt ./
 COPY requirements-dev.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 code
 COPY backend/ ./backend/
 COPY backend/ ./backend/
 COPY pyproject.toml ./
 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
 # Create necessary directories
 RUN mkdir -p /app/data /app/logs /app/archive
 RUN mkdir -p /app/data /app/logs /app/archive
 
 
@@ -25,8 +37,19 @@ ENV PYTHONUNBUFFERED=1
 ENV DATA_DIR=/app/data
 ENV DATA_DIR=/app/data
 ENV TESTING=1
 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
 # 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
     successful_prints = successful_result.scalar() or 0
 
 
     failed_result = await db.execute(
     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
     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
     # Total elapsed time — PrintLogEntry stores duration_seconds directly so we
     # can sum it server-side. Rows missing duration fall back to the slicer
     # can sum it server-side. Rows missing duration fall back to the slicer
     # estimate from the archive (joined for that case only).
     # estimate from the archive (joined for that case only).
@@ -990,6 +1003,7 @@ async def get_archive_stats(
         total_prints=total_prints,
         total_prints=total_prints,
         successful_prints=successful_prints,
         successful_prints=successful_prints,
         failed_prints=failed_prints,
         failed_prints=failed_prints,
+        cancelled_prints=cancelled_prints,
         total_print_time_hours=round(total_time, 1),
         total_print_time_hours=round(total_time, 1),
         total_filament_grams=round(total_filament, 1),
         total_filament_grams=round(total_filament, 1),
         total_cost=round(total_cost, 2),
         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,
     get_ffmpeg_path,
     is_chamber_image_model,
     is_chamber_image_model,
     read_next_chamber_frame,
     read_next_chamber_frame,
+    rtsp_socket_timeout_flag,
     test_camera_connection,
     test_camera_connection,
 )
 )
 from backend.app.services.camera_fanout import (
 from backend.app.services.camera_fanout import (
@@ -348,8 +349,11 @@ async def generate_rtsp_mjpeg_stream(
         "tcp",
         "tcp",
         "-rtsp_flags",
         "-rtsp_flags",
         "prefer_tcp",
         "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",
         "-buffer_size",
         "1024000",  # 1MB buffer
         "1024000",  # 1MB buffer
         "-max_delay",
         "-max_delay",

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

@@ -1099,6 +1099,22 @@ async def _collect_support_info() -> dict:
     except Exception:
     except Exception:
         logger.debug("Failed to collect WebSocket info", exc_info=True)
         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
     return info
 
 
 
 

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

@@ -147,6 +147,10 @@ class ArchiveStats(BaseModel):
     total_prints: int
     total_prints: int
     successful_prints: int
     successful_prints: int
     failed_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_print_time_hours: float
     total_filament_grams: float
     total_filament_grams: float
     total_cost: float
     total_cost: float

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

@@ -11,6 +11,7 @@ import os
 import shutil
 import shutil
 import ssl
 import ssl
 import struct
 import struct
+import subprocess
 import uuid
 import uuid
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
@@ -24,6 +25,9 @@ JPEG_END = b"\xff\xd9"
 # Cache the ffmpeg path after first lookup
 # Cache the ffmpeg path after first lookup
 _ffmpeg_path: str | None = None
 _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).
 # 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.
 # The cleanup task in routes/camera.py checks this set to avoid killing active captures.
 _active_capture_pids: set[int] = set()
 _active_capture_pids: set[int] = set()
@@ -66,6 +70,69 @@ def get_ffmpeg_path() -> str | None:
     return ffmpeg_path
     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:
 def supports_rtsp(model: str | None) -> bool:
     """Check if printer model supports RTSP camera streaming.
     """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")
         logger.error("ffmpeg not found - required for RTSP streaming")
         return
         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://
     # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
     proxy_server = None
     proxy_server = None
     effective_url = url
     effective_url = url
@@ -715,7 +717,9 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
         "tcp",
         "tcp",
         "-rtsp_flags",
         "-rtsp_flags",
         "prefer_tcp",
         "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",
         "30000000",
         "-buffer_size",
         "-buffer_size",
         "1024000",
         "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_result = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*base_filter)))
         total_prints = total_result.scalar() or 0
         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(
         failed_result = await self.db.execute(
             select(func.count(PrintLogEntry.id)).where(
             select(func.count(PrintLogEntry.id)).where(
                 and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
                 and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
@@ -82,7 +87,14 @@ class FailureAnalysisService:
         )
         )
         failed_prints = failed_result.scalar() or 0
         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
         # Failures by reason
         reason_result = await self.db.execute(
         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_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(
             week_failed = await self.db.execute(
                 select(func.count(PrintLogEntry.id)).where(
                 select(func.count(PrintLogEntry.id)).where(
                     and_(*week_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
                     and_(*week_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
@@ -195,8 +210,10 @@ class FailureAnalysisService:
             )
             )
 
 
             total = week_total.scalar() or 0
             total = week_total.scalar() or 0
+            successful = week_successful.scalar() or 0
             failed = week_failed.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(
             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 import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import async_session, run_with_retry
 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.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 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 (
 from backend.app.services.bambu_ftp import (
     cache_3mf_download,
     cache_3mf_download,
     delete_file_async,
     delete_file_async,
@@ -855,8 +858,19 @@ class PrintScheduler:
         # Check if user prefers lowest remaining filament when multiple spools match
         # Check if user prefers lowest remaining filament when multiple spools match
         prefer_lowest = await self._get_bool_setting(db, "prefer_lowest_filament")
         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
         # 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:
     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.
         """Build an AMS mapping directly from force-color overrides without a 3MF.
@@ -1015,8 +1029,156 @@ class PrintScheduler:
         except ValueError:
         except ValueError:
             return False
             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(
     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:
     ) -> list[int] | None:
         """Match required filaments to loaded filaments and build AMS mapping.
         """Match required filaments to loaded filaments and build AMS mapping.
 
 
@@ -1063,9 +1225,11 @@ class PrintScheduler:
             if req_nozzle_id is not None:
             if req_nozzle_id is not None:
                 available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
                 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:
             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
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
             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]}"
                         f"using color matching among trays: {[f['global_tray_id'] for f in idx_matches]}"
                     )
                     )
                     if prefer_lowest:
                     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
                     # Use color matching within this subset
                     for f in idx_matches:
                     for f in idx_matches:
                         f_color = f.get("color", "")
                         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()
         printer = await printer_factory()
         archive = PrintArchive(
         archive = PrintArchive(
             filename="ghost.3mf",
             filename="ghost.3mf",
-            file_path="/tmp/nope-does-not-exist.3mf",
+            file_path="/nonexistent/ghost.3mf",
             file_size=0,
             file_size=0,
             status="completed",
             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.
         """Verify all Python modules can be imported without errors.
 
 
         This catches syntax errors and missing dependencies.
         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 importlib
-        import sys
 
 
         # Modules to test importing
         # Modules to test importing
         modules = [
         modules = [
@@ -235,9 +253,6 @@ class TestModuleImports:
         errors = []
         errors = []
         for module_name in modules:
         for module_name in modules:
             try:
             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)
                 importlib.import_module(module_name)
             except Exception as e:
             except Exception as e:
                 errors.append(f"{module_name}: {type(e).__name__}: {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%)
         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:
 class TestBuildLoadedFilamentsTrayInfoIdx:
     """Test tray_info_idx extraction in _build_loaded_filaments."""
     """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 "
         "restart-recovery handler must capture the printer's existing-videos "
         "baseline so the completion-time scan can set-diff to find the new file"
         "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:
     environment:
       - BAMBUDDY_TEST_URL=http://integration:8000
       - BAMBUDDY_TEST_URL=http://integration:8000
       - TESTING=1
       - 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:
     volumes:
       - ./backend:/app/backend:ro
       - ./backend:/app/backend:ro
 
 

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

@@ -14,6 +14,7 @@ const mockStats = {
   total_prints: 150,
   total_prints: 150,
   successful_prints: 140,
   successful_prints: 140,
   failed_prints: 10,
   failed_prints: 10,
+  cancelled_prints: 0,
   total_print_time_hours: 500.5,
   total_print_time_hours: 500.5,
   total_filament_grams: 5500,
   total_filament_grams: 5500,
   total_cost: 125.50,
   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(
       server.use(
         http.get('/api/v1/archives/stats', () =>
         http.get('/api/v1/archives/stats', () =>
           HttpResponse.json({
           HttpResponse.json({
@@ -227,6 +231,7 @@ describe('StatsPage', () => {
             total_prints: 100,
             total_prints: 100,
             successful_prints: 40,
             successful_prints: 40,
             failed_prints: 20,
             failed_prints: 20,
+            cancelled_prints: 35,
           }),
           }),
         ),
         ),
       );
       );
@@ -234,7 +239,13 @@ describe('StatsPage', () => {
 
 
       await waitFor(() => {
       await waitFor(() => {
         expect(screen.getByText('Success Rate')).toBeInTheDocument();
         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;
   total_prints: number;
   successful_prints: number;
   successful_prints: number;
   failed_prints: number;
   failed_prints: number;
+  cancelled_prints: number;
   total_print_time_hours: number;
   total_print_time_hours: number;
   total_filament_grams: number;
   total_filament_grams: number;
   total_cost: number;
   total_cost: number;

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

@@ -487,11 +487,24 @@ export function BugReportBubble() {
               )}
               )}
 
 
               {(viewState === 'stopping' || viewState === 'submitting') && (
               {(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" />
                   <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')}
                     {viewState === 'stopping' ? t('bugReport.stoppingLogs') : t('bugReport.submitting')}
                   </p>
                   </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>
                 </div>
               )}
               )}
 
 

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Zeitgenauigkeit',
     timeAccuracy: 'Zeitgenauigkeit',
     successful: 'Erfolgreich:',
     successful: 'Erfolgreich:',
     failed: 'Fehlgeschlagen:',
     failed: 'Fehlgeschlagen:',
+    cancelled: 'Abgebrochen:',
     perfectEstimate: '100% = perfekte Schätzung',
     perfectEstimate: '100% = perfekte Schätzung',
     noTimeAccuracyData: 'Noch keine Zeitgenauigkeitsdaten',
     noTimeAccuracyData: 'Noch keine Zeitgenauigkeitsdaten',
     noFilamentData: 'Keine Filamentdaten verfügbar',
     noFilamentData: 'Keine Filamentdaten verfügbar',
@@ -3054,6 +3055,11 @@ export default {
     collectItem10: 'Python-Paketversionen',
     collectItem10: 'Python-Paketversionen',
     collectItem11: 'Datenbankzustandsprüfungen',
     collectItem11: 'Datenbankzustandsprüfungen',
     collectItem12: 'Docker-Umgebungsdetails',
     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
   // File manager
@@ -5680,6 +5686,10 @@ export default {
     maxDuration: 'Stoppt automatisch nach {{minutes}} Min.',
     maxDuration: 'Stoppt automatisch nach {{minutes}} Min.',
     stoppingLogs: 'Protokolle sammeln & senden...',
     stoppingLogs: 'Protokolle sammeln & senden...',
     submitting: 'Fehlerbericht wird gesendet...',
     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!',
     submitSuccess: 'Fehlerbericht erfolgreich gesendet!',
     submitFailed: 'Fehlerbericht konnte nicht gesendet werden',
     submitFailed: 'Fehlerbericht konnte nicht gesendet werden',
     diagnosticChecking: 'Druckerverbindungen werden geprüft...',
     diagnosticChecking: 'Druckerverbindungen werden geprüft...',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Time Accuracy',
     timeAccuracy: 'Time Accuracy',
     successful: 'Successful:',
     successful: 'Successful:',
     failed: 'Failed:',
     failed: 'Failed:',
+    cancelled: 'Cancelled:',
     perfectEstimate: '100% = perfect estimate',
     perfectEstimate: '100% = perfect estimate',
     noTimeAccuracyData: 'No time accuracy data yet',
     noTimeAccuracyData: 'No time accuracy data yet',
     noFilamentData: 'No filament data available',
     noFilamentData: 'No filament data available',
@@ -3057,6 +3058,11 @@ export default {
     collectItem10: 'Python package versions',
     collectItem10: 'Python package versions',
     collectItem11: 'Database health checks',
     collectItem11: 'Database health checks',
     collectItem12: 'Docker environment details',
     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
   // File manager
@@ -5691,6 +5697,10 @@ export default {
     maxDuration: 'Auto-stops after {{minutes}} min',
     maxDuration: 'Auto-stops after {{minutes}} min',
     stoppingLogs: 'Collecting logs & submitting...',
     stoppingLogs: 'Collecting logs & submitting...',
     submitting: 'Submitting bug report...',
     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!',
     submitSuccess: 'Bug report submitted successfully!',
     submitFailed: 'Failed to submit bug report',
     submitFailed: 'Failed to submit bug report',
     diagnosticChecking: 'Checking printer connections...',
     diagnosticChecking: 'Checking printer connections...',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Precisión temporal',
     timeAccuracy: 'Precisión temporal',
     successful: 'Con éxito:',
     successful: 'Con éxito:',
     failed: 'Fallidas:',
     failed: 'Fallidas:',
+    cancelled: 'Canceladas:',
     perfectEstimate: '100% = estimación perfecta',
     perfectEstimate: '100% = estimación perfecta',
     noTimeAccuracyData: 'Aún no hay datos de precisión temporal',
     noTimeAccuracyData: 'Aún no hay datos de precisión temporal',
     noFilamentData: 'No hay datos de filamento disponibles',
     noFilamentData: 'No hay datos de filamento disponibles',
@@ -3057,6 +3058,11 @@ export default {
     collectItem10: 'Versiones de los paquetes de Python',
     collectItem10: 'Versiones de los paquetes de Python',
     collectItem11: 'Comprobaciones de estado de la base de datos',
     collectItem11: 'Comprobaciones de estado de la base de datos',
     collectItem12: 'Detalles del entorno de Docker',
     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
   // File manager
@@ -5689,6 +5695,10 @@ export default {
     maxDuration: 'Se detiene automáticamente tras {{minutes}} min',
     maxDuration: 'Se detiene automáticamente tras {{minutes}} min',
     stoppingLogs: 'Recopilando registros y enviando...',
     stoppingLogs: 'Recopilando registros y enviando...',
     submitting: 'Enviando el informe de error...',
     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!',
     submitSuccess: '¡Informe de error enviado correctamente!',
     submitFailed: 'Error al enviar el informe de error',
     submitFailed: 'Error al enviar el informe de error',
     diagnosticChecking: 'Comprobando las conexiones de las impresoras...',
     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',
     timeAccuracy: 'Précision du temps',
     successful: 'Succès :',
     successful: 'Succès :',
     failed: 'Échecs :',
     failed: 'Échecs :',
+    cancelled: 'Annulés :',
     perfectEstimate: '100% = estimation parfaite',
     perfectEstimate: '100% = estimation parfaite',
     noTimeAccuracyData: 'Pas encore de données de précision',
     noTimeAccuracyData: 'Pas encore de données de précision',
     noFilamentData: 'Aucune donnée de filament',
     noFilamentData: 'Aucune donnée de filament',
@@ -3043,6 +3044,11 @@ export default {
     collectItem10: 'Versions packages Python',
     collectItem10: 'Versions packages Python',
     collectItem11: 'Santé base de données',
     collectItem11: 'Santé base de données',
     collectItem12: 'Détails environnement Docker',
     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
   // File manager
@@ -5670,6 +5676,10 @@ export default {
     maxDuration: 'Arrêt auto après {{minutes}} min',
     maxDuration: 'Arrêt auto après {{minutes}} min',
     stoppingLogs: 'Collecte des journaux & envoi...',
     stoppingLogs: 'Collecte des journaux & envoi...',
     submitting: 'Envoi du rapport de bug...',
     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 !',
     submitSuccess: 'Rapport de bug envoyé avec succès !',
     submitFailed: 'Échec de l\'envoi du rapport de bug',
     submitFailed: 'Échec de l\'envoi du rapport de bug',
     diagnosticChecking: 'Vérification des connexions des imprimantes...',
     diagnosticChecking: 'Vérification des connexions des imprimantes...',

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Accuratezza tempo',
     timeAccuracy: 'Accuratezza tempo',
     successful: 'Riuscite:',
     successful: 'Riuscite:',
     failed: 'Fallite:',
     failed: 'Fallite:',
+    cancelled: 'Annullate:',
     perfectEstimate: '100% = stima perfetta',
     perfectEstimate: '100% = stima perfetta',
     noTimeAccuracyData: 'Nessun dato accuratezza tempo',
     noTimeAccuracyData: 'Nessun dato accuratezza tempo',
     noFilamentData: 'Nessun dato filamento',
     noFilamentData: 'Nessun dato filamento',
@@ -3042,6 +3043,11 @@ export default {
     collectItem10: 'Versioni dei pacchetti Python',
     collectItem10: 'Versioni dei pacchetti Python',
     collectItem11: 'Controlli di integrità del database',
     collectItem11: 'Controlli di integrità del database',
     collectItem12: 'Dettagli dell\'ambiente Docker',
     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
   // File manager
@@ -5669,6 +5675,10 @@ export default {
     maxDuration: 'Arresto automatico dopo {{minutes}} min',
     maxDuration: 'Arresto automatico dopo {{minutes}} min',
     stoppingLogs: 'Raccolta log & invio...',
     stoppingLogs: 'Raccolta log & invio...',
     submitting: 'Invio segnalazione bug...',
     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!',
     submitSuccess: 'Segnalazione bug inviata con successo!',
     submitFailed: 'Impossibile inviare la segnalazione bug',
     submitFailed: 'Impossibile inviare la segnalazione bug',
     diagnosticChecking: 'Verifica delle connessioni delle stampanti...',
     diagnosticChecking: 'Verifica delle connessioni delle stampanti...',

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

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

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

@@ -1217,6 +1217,7 @@ export default {
     timeAccuracy: 'Precisão do Tempo',
     timeAccuracy: 'Precisão do Tempo',
     successful: 'Bem-sucedido:',
     successful: 'Bem-sucedido:',
     failed: 'Falhou:',
     failed: 'Falhou:',
+    cancelled: 'Cancelado:',
     perfectEstimate: '100% = estimativa perfeita',
     perfectEstimate: '100% = estimativa perfeita',
     noTimeAccuracyData: 'Nenhum dado de precisão de tempo disponível',
     noTimeAccuracyData: 'Nenhum dado de precisão de tempo disponível',
     noFilamentData: 'Nenhum dado de filamento disponível',
     noFilamentData: 'Nenhum dado de filamento disponível',
@@ -3042,6 +3043,11 @@ export default {
     collectItem10: 'Versões de pacotes Python',
     collectItem10: 'Versões de pacotes Python',
     collectItem11: 'Verificações de integridade do banco de dados',
     collectItem11: 'Verificações de integridade do banco de dados',
     collectItem12: 'Detalhes do ambiente Docker',
     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
   // File manager
@@ -5669,6 +5675,10 @@ export default {
     maxDuration: 'Para automaticamente após {{minutes}} min',
     maxDuration: 'Para automaticamente após {{minutes}} min',
     stoppingLogs: 'Coletando logs & enviando...',
     stoppingLogs: 'Coletando logs & enviando...',
     submitting: 'Enviando relatório de bug...',
     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!',
     submitSuccess: 'Relatório de bug enviado com sucesso!',
     submitFailed: 'Falha ao enviar relatório de bug',
     submitFailed: 'Falha ao enviar relatório de bug',
     diagnosticChecking: 'Verificando as conexões das impressoras...',
     diagnosticChecking: 'Verificando as conexões das impressoras...',

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

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

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

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

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

@@ -6,6 +6,7 @@ import {
   Clock,
   Clock,
   CheckCircle,
   CheckCircle,
   XCircle,
   XCircle,
+  Ban,
   DollarSign,
   DollarSign,
   Target,
   Target,
   Zap,
   Zap,
@@ -191,14 +192,20 @@ function SuccessRateWidget({
     total_prints: number;
     total_prints: number;
     successful_prints: number;
     successful_prints: number;
     failed_prints: number;
     failed_prints: number;
+    cancelled_prints?: number;
     prints_by_printer: Record<string, number>;
     prints_by_printer: Record<string, number>;
   } | undefined;
   } | undefined;
   printerMap: Map<string, string>;
   printerMap: Map<string, string>;
   size?: 1 | 2 | 4;
   size?: 1 | 2 | 4;
 }) {
 }) {
   const { t } = useTranslation();
   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;
     : 0;
 
 
   // Scale gauge size based on widget size
   // 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-bambu-gray">{t('stats.failed')}</span>
             <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
             <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
           </div>
           </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>
         </div>
         {/* Show per-printer breakdown when expanded */}
         {/* Show per-printer breakdown when expanded */}
         {size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (
         {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}
               title={!debugLoggingState?.enabled ? t('support.enableDebugFirst', 'Enable debug logging first') : undefined}
             >
             >
               {bundleDownloading && <Loader2 className="w-4 h-4 animate-spin" />}
               {bundleDownloading && <Loader2 className="w-4 h-4 animate-spin" />}
-              {t('common.download', 'Download')}
+              {bundleDownloading
+                ? t('support.bundleGenerating', 'Generating...')
+                : t('common.download', 'Download')}
             </button>
             </button>
           </div>
           </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 */}
           {/* Error message */}
           {bundleError && (
           {bundleError && (
             <div className="p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
             <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-cov>=4.1.0
 pytest-xdist>=3.5.0
 pytest-xdist>=3.5.0
 pytest-timeout>=2.4.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
 httpx>=0.27.0
 ruff>=0.8.0
 ruff>=0.8.0
 pre-commit>=4.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.
 # would silently keep installing the vulnerable 2.6.x line.
 urllib3>=2.7.0
 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)
 # Plate Detection (optional - enables build plate empty detection)
 opencv-python-headless>=4.8.0
 opencv-python-headless>=4.8.0
 numpy>=1.24.0
 numpy>=1.24.0

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/index-BW850Rth.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
static/assets/index-y4woBlMv.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Деякі файли не було показано, через те що забагато файлів було змінено