Просмотр исходного кода

fix(slicer): bound slices by silence, not by total slicing time (#2730)

    A heavy MakerWorld model — one Bambu Studio also takes a long time over —
    failed after five minutes with "Slicer sidecar unreachable". The sidecar
    was reachable the whole time and still slicing when we hung up on it.

    SlicerApiService carried a hardcoded 300s timeout, passed to httpx as a
    bare float so it covered connect, read, write and pool alike. On a single
    long request that is not a health check, it is a cap on how long a model is
    allowed to take. And because httpx.ReadTimeout subclasses RequestError,
    expiry landed in the same handler as a refused connection and was reported
    as an unreachable sidecar — so the reporter went and updated their sidecar
    container, which was never the problem.

    The information to do better was already being collected. _poll_progress
    polls /slice/progress/{id} once a second alongside the blocking POST to
    drive the live progress toast, so at minute five Bambuddy had fresh
    evidence the slicer was working. It killed the request anyway.

    So the read timeout comes off the HTTP call and the poller supervises
    instead: the deadline moves forward on every progress update, and only
    genuine silence ends the wait. A model that keeps reporting runs to
    completion however long it takes. Connect and pool keep short timeouts —
    a sidecar that will not accept a connection is unreachable and should
    still say so quickly.

    Only a *changed* progress payload counts as alive. The sidecar re-serves
    its last snapshot on every poll, so counting repeats would leave the
    watchdog unable to detect a stall at all.

    The window is floored at three poll intervals: liveness can only be
    observed as fast as the poller ticks, so anything shorter would expire in
    the gap between two polls and fail every slice instantly.

    New setting slicer_stall_timeout_minutes (Settings > Workflow > Slicer),
    default 15, range 1-240, alongside the sidecar URL and gated on
    use_slicer_api like its neighbours. Sidecars too old to report progress
    have no liveness signal, so for those the same number bounds total elapsed
    time — the old behaviour, configurable and no longer 300s flat. The
    message says which case applies and where to change it.

    SlicerTimeoutError is its own type and maps to 504, not 502: the sidecar
    answered throughout, we stopped waiting. Connection failures keep
    SlicerApiUnavailableError. The preview slice path gets the same treatment.
maziggy 1 месяц назад
Родитель
Сommit
bade12ff49

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 2 - 0
backend/app/api/routes/archives.py

@@ -3905,6 +3905,7 @@ async def _try_preview_slice_filaments(
     """
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_preview import get_preview_filaments
+    from backend.app.services.slicer_api import get_stall_timeout_seconds
 
     preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
     if preferred == "orcaslicer":
@@ -3930,6 +3931,7 @@ async def _try_preview_slice_filaments(
         file_name=file_path.name,
         api_url=api_url,
         request_id=request_id,
+        timeout_seconds=await get_stall_timeout_seconds(db),
     )
 
 

+ 13 - 1
backend/app/api/routes/library.py

@@ -3082,6 +3082,7 @@ async def _try_preview_slice_filaments(
     """
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_preview import get_preview_filaments
+    from backend.app.services.slicer_api import get_stall_timeout_seconds
 
     preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
     if preferred == "orcaslicer":
@@ -3107,6 +3108,7 @@ async def _try_preview_slice_filaments(
         file_name=file_path.name,
         api_url=api_url,
         request_id=request_id,
+        timeout_seconds=await get_stall_timeout_seconds(db),
     )
 
 
@@ -3607,6 +3609,8 @@ async def _run_slicer_with_fallback(
         SlicerApiService,
         SlicerApiUnavailableError,
         SlicerInputError,
+        SlicerTimeoutError,
+        get_stall_timeout_seconds,
     )
 
     user: User | None = None
@@ -3717,7 +3721,9 @@ async def _run_slicer_with_fallback(
     # gates the toggle on the picked printer matching the design's target,
     # so this path never re-targets across printer models.
     embedded_mode = bool(request.use_embedded_settings and is_3mf)
-    service = SlicerApiService(api_url)
+    # Bounds silence rather than total slicing time (#2730), so a heavy model
+    # that keeps reporting progress runs to completion however long it takes.
+    service = SlicerApiService(api_url, timeout_seconds=await get_stall_timeout_seconds(db))
 
     # #1493: cross-nozzle-class re-slice (single <-> dual). Without
     # intervention the slicer rejects with either "G-code in unprintable
@@ -3960,6 +3966,12 @@ async def _run_slicer_with_fallback(
             used_embedded_settings = True
     except SlicerInputError as exc:
         raise HTTPException(status_code=400, detail=str(exc)) from exc
+    except SlicerTimeoutError as exc:
+        # 504, not 502: the sidecar answered for the whole run, we stopped
+        # waiting. Reported separately so the user is told the slice ran out of
+        # time and where to change that, rather than that the sidecar is
+        # unreachable — which is what a read timeout used to look like (#2730).
+        raise HTTPException(status_code=504, detail=str(exc)) from exc
     except SlicerApiServerError as exc:
         raise HTTPException(status_code=502, detail=str(exc)) from exc
     except SlicerApiUnavailableError as exc:

+ 16 - 0
backend/app/schemas/settings.py

@@ -285,6 +285,21 @@ class AppSettings(BaseModel):
         default="",
         description="BambuStudio sidecar URL (e.g. http://localhost:3001). Empty falls back to the BAMBU_STUDIO_API_URL env var.",
     )
+    # How long to keep waiting on a slice that isn't finishing. Measured against
+    # the sidecar's progress channel, not total elapsed time — a heavy model can
+    # legitimately slice for half an hour, and a wall-clock ceiling cannot tell
+    # that apart from a stalled one (#2730). Sidecars too old to report progress
+    # fall back to using this as a total-elapsed ceiling, which is the pre-#2730
+    # behaviour with a configurable number.
+    slicer_stall_timeout_minutes: int = Field(
+        default=15,
+        ge=1,
+        le=240,
+        description=(
+            "Give up on a slice after this many minutes with no progress from the sidecar. "
+            "On sidecars that do not report progress, applies to total slicing time instead."
+        ),
+    )
 
     # Prometheus metrics endpoint
     prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
@@ -583,6 +598,7 @@ class AppSettingsUpdate(BaseModel):
     use_slicer_api: bool | None = None
     orcaslicer_api_url: str | None = None
     bambu_studio_api_url: str | None = None
+    slicer_stall_timeout_minutes: int | None = Field(default=None, ge=1, le=240)
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)

+ 6 - 1
backend/app/services/slice_preview.py

@@ -63,6 +63,7 @@ async def get_preview_filaments(
     file_name: str,
     api_url: str,
     request_id: str | None = None,
+    timeout_seconds: float | None = None,
 ) -> list[dict] | None:
     """Run a preview slice for ``plate_id``, parse the resulting slice_info,
     and return the per-plate filament list.
@@ -92,7 +93,11 @@ async def get_preview_filaments(
             return cached
 
         try:
-            async with SlicerApiService(base_url=api_url) as svc:
+            # Preview slices are bounded the same way as real ones (#2730):
+            # a heavy plate can take a long time and must not be cut off
+            # while the slicer is visibly working.
+            svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
+            async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
                 result = await svc.slice_without_profiles(
                     model_bytes=file_bytes,
                     model_filename=file_name,

+ 211 - 50
backend/app/services/slicer_api.py

@@ -11,6 +11,7 @@ under the hood, response body is raw G-code or 3MF with metadata in the
 import asyncio
 import io
 import logging
+import time
 import zipfile
 from collections.abc import Callable
 from typing import NamedTuple
@@ -40,6 +41,18 @@ class SlicerInputError(SlicerApiError):
     """Sidecar rejected the input as invalid (4xx)."""
 
 
+class SlicerTimeoutError(SlicerApiError):
+    """We gave up waiting on a slice that never finished.
+
+    Kept apart from ``SlicerApiUnavailableError`` because they call for
+    opposite reactions and used to be reported as the same thing: an
+    ``httpx.ReadTimeout`` is a subclass of ``RequestError``, so a slice that
+    simply took a long time surfaced as "Slicer sidecar unreachable" — sending
+    the reporter of #2730 off to check a sidecar that was reachable throughout
+    and still slicing when we hung up on it.
+    """
+
+
 class SliceResult(NamedTuple):
     """Result of a slice operation."""
 
@@ -51,6 +64,36 @@ class SliceResult(NamedTuple):
 
 _shared_http_client: httpx.AsyncClient | None = None
 
+# Fallback for callers that don't pass one (tests, and any path that runs
+# without a DB session to read the setting from). The user-facing value is
+# ``slicer_stall_timeout_minutes`` under Settings -> Workflow -> Slicer.
+DEFAULT_SLICE_STALL_TIMEOUT_SECONDS = 15 * 60.0
+
+# How often the progress poller ticks. Also the granularity of the stall check,
+# since a missed tick is what the stall clock is counting.
+_PROGRESS_POLL_INTERVAL = 1.0
+
+
+async def get_stall_timeout_seconds(db) -> float:
+    """Read ``slicer_stall_timeout_minutes`` (Settings -> Workflow -> Slicer).
+
+    Falls back to the default on anything unparseable rather than failing the
+    slice — a bad settings row must not be the reason a print doesn't happen.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    try:
+        raw = await get_setting(db, "slicer_stall_timeout_minutes")
+    except Exception:
+        return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
+    try:
+        minutes = int(str(raw).strip())
+    except (TypeError, ValueError):
+        return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
+    if minutes < 1:
+        return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
+    return float(minutes) * 60.0
+
 
 def _format_sidecar_error(response: httpx.Response) -> str:
     """Build a human-readable error string from a sidecar 4xx/5xx response.
@@ -149,6 +192,65 @@ def _guess_model_content_type(filename: str) -> str:
     return "application/octet-stream"
 
 
+class _Liveness:
+    """Tracks when the slicer last showed a sign of life.
+
+    ``deadline`` is what the slice waits against, and it moves forward on every
+    genuine progress update. A slice therefore fails only after the configured
+    window of *silence*, however long the whole thing has been running (#2730).
+
+    ``progress_supported`` stays False for sidecars that never answer the
+    progress endpoint. Those give us nothing to judge liveness by, so the caller
+    treats the same window as a total-elapsed ceiling rather than pretending a
+    stall can be detected.
+    """
+
+    def __init__(self, window_seconds: float, poll_interval: float = _PROGRESS_POLL_INTERVAL) -> None:
+        # Liveness can only be observed as often as the poller ticks, so a
+        # window shorter than a few ticks would expire in the gap between two
+        # polls and fail every slice instantly, however healthy. The settings
+        # schema already floors the user-facing value at a minute; this guards
+        # the constructor, which tests and any future caller can pass anything.
+        self.window_seconds = max(window_seconds, poll_interval * 3)
+        self.progress_supported = False
+        self.started_at = time.monotonic()
+        self._last_alive = self.started_at
+
+    def saw_progress_endpoint(self) -> None:
+        self.progress_supported = True
+
+    def mark_alive(self) -> None:
+        self._last_alive = time.monotonic()
+
+    @property
+    def deadline(self) -> float:
+        """Monotonic time at which we stop waiting."""
+        base = self._last_alive if self.progress_supported else self.started_at
+        return base + self.window_seconds
+
+    def silent_for(self) -> float:
+        return time.monotonic() - self._last_alive
+
+    def elapsed(self) -> float:
+        return time.monotonic() - self.started_at
+
+    def timeout_message(self) -> str:
+        minutes = self.window_seconds / 60
+        if self.progress_supported:
+            return (
+                f"The slicer stopped reporting progress for {minutes:.0f} minutes "
+                f"(slicing had been running for {self.elapsed() / 60:.0f} minutes). "
+                "Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer if this model "
+                "legitimately needs longer between progress updates."
+            )
+        return (
+            f"Slicing did not finish within {minutes:.0f} minutes, and this sidecar does not "
+            "report progress, so there was no way to tell a slow model from a stalled one. "
+            "Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer, or update the "
+            "sidecar to a version that reports progress."
+        )
+
+
 class SlicerApiService:
     """Talks to an OrcaSlicer / BambuStudio API sidecar."""
 
@@ -157,10 +259,25 @@ class SlicerApiService:
         base_url: str,
         *,
         client: httpx.AsyncClient | None = None,
-        timeout_seconds: float = 300.0,
+        timeout_seconds: float = DEFAULT_SLICE_STALL_TIMEOUT_SECONDS,
     ) -> None:
+        """``timeout_seconds`` bounds *silence*, not total slicing time (#2730).
+
+        While a slice is running Bambuddy polls the sidecar's progress channel
+        once a second, so it can tell a model that is merely slow from one that
+        has stopped: the clock is reset by every progress update, and only runs
+        out when the slicer has said nothing for this long. A heavy model that
+        keeps reporting will run to completion however long it takes.
+
+        Sidecars too old to report progress have no liveness signal to offer, so
+        for those the same number bounds total elapsed time — the pre-#2730
+        behaviour, but configurable and no longer five minutes flat.
+        """
         self.base_url = base_url.rstrip("/")
         self.timeout_seconds = timeout_seconds
+        # Instance-level so tests can compress the timing; production always
+        # uses the module default.
+        self.progress_poll_interval = _PROGRESS_POLL_INTERVAL
         if client is not None:
             self._client = client
             self._owns_client = False
@@ -217,6 +334,8 @@ class SlicerApiService:
         self,
         request_id: str,
         on_progress: Callable[[dict], None],
+        *,
+        liveness: "_Liveness | None" = None,
     ) -> None:
         """Poll the sidecar's progress endpoint at ~1Hz and forward each
         snapshot to ``on_progress``. Runs until cancelled.
@@ -232,14 +351,27 @@ class SlicerApiService:
         slice grace expiry) just costs a few wasted GETs that the cancel
         will stop. Network errors and non-JSON 5xx are swallowed; the
         next tick retries.
+
+        When ``liveness`` is supplied this doubles as the stall watchdog: every
+        200 carrying a *changed* payload marks the slicer alive, which is what
+        keeps the slice's deadline moving (#2730). An unchanged payload
+        deliberately does not count — the sidecar re-serves its last snapshot on
+        every poll, so treating a repeat as progress would leave the watchdog
+        unable to detect a stall at all.
         """
         url = f"{self.base_url}/slice/progress/{request_id}"
+        last_payload: dict | None = None
         while True:
             try:
                 response = await self._client.get(url, timeout=5.0)
                 if response.status_code == 200:
                     payload = response.json()
                     if isinstance(payload, dict):
+                        if liveness is not None:
+                            liveness.saw_progress_endpoint()
+                            if payload != last_payload:
+                                liveness.mark_alive()
+                        last_payload = payload
                         on_progress(payload)
                 # 404 / other 4xx = no progress available (yet, or ever
                 # for older sidecars). Keep polling — the outer slice
@@ -249,10 +381,85 @@ class SlicerApiService:
                 # returns a non-JSON 5xx. Don't crash the poller.
                 pass
             try:
-                await asyncio.sleep(1.0)
+                await asyncio.sleep(self.progress_poll_interval)
             except asyncio.CancelledError:
                 return
 
+    async def _post_slice(
+        self,
+        *,
+        files: list | dict,
+        data: dict,
+        request_id: str | None,
+        on_progress: Callable[[dict], None] | None,
+    ) -> httpx.Response:
+        """POST /slice, supervised by the progress channel rather than a clock.
+
+        Before #2730 this was a plain ``httpx`` call with a flat 300 s timeout on
+        every phase. A genuinely heavy model — the reporter's was a MakerWorld
+        model that Bambu Studio also took a long time over — hit the ceiling
+        while it was still slicing perfectly happily, and because
+        ``httpx.ReadTimeout`` is a ``RequestError`` it was reported as "Slicer
+        sidecar unreachable". Meanwhile Bambuddy was polling the sidecar's
+        progress endpoint once a second and could see the thing working.
+
+        So the read timeout comes off the HTTP call and the poller supervises
+        instead: the deadline is pushed forward by every progress update, and
+        only a genuine silence ends the wait. Connect and pool keep short
+        timeouts — a sidecar that won't accept the connection at all is
+        unreachable, and should still say so quickly.
+        """
+        liveness = _Liveness(self.timeout_seconds, self.progress_poll_interval)
+
+        # Poll whenever we have a request_id, even if the caller wants no
+        # progress callbacks: the poll is what makes stall detection possible,
+        # and one GET per second is cheaper than a wrongly-cancelled slice.
+        progress_task: asyncio.Task | None = None
+        if request_id is not None:
+            progress_task = asyncio.create_task(
+                self._poll_progress(request_id, on_progress or (lambda _payload: None), liveness=liveness),
+                name=f"slicer-progress-{request_id}",
+            )
+
+        post_task = asyncio.create_task(
+            self._client.post(
+                f"{self.base_url}/slice",
+                files=files,
+                data=data,
+                timeout=httpx.Timeout(connect=30.0, read=None, write=None, pool=30.0),
+            ),
+            name="slicer-slice-post",
+        )
+
+        try:
+            while True:
+                remaining = liveness.deadline - time.monotonic()
+                if remaining <= 0:
+                    post_task.cancel()
+                    logger.warning(
+                        "Slice abandoned after %.0fs (silent for %.0fs, progress channel %s)",
+                        liveness.elapsed(),
+                        liveness.silent_for(),
+                        "available" if liveness.progress_supported else "unavailable",
+                    )
+                    raise SlicerTimeoutError(liveness.timeout_message())
+                # Re-check at poll granularity so a progress update that lands
+                # mid-wait extends the deadline promptly.
+                done, _pending = await asyncio.wait({post_task}, timeout=min(remaining, self.progress_poll_interval))
+                if post_task in done:
+                    break
+        finally:
+            if progress_task is not None:
+                progress_task.cancel()
+            # Await both so neither is left pending — a cancelled POST still
+            # needs its connection released back to the pool.
+            await asyncio.gather(post_task, progress_task or asyncio.sleep(0), return_exceptions=True)
+
+        try:
+            return post_task.result()
+        except httpx.RequestError as exc:
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+
     async def slice_with_profiles(
         self,
         *,
@@ -328,30 +535,7 @@ class SlicerApiService:
         # and surfaces structured updates via on_progress. Uses a
         # short-tick poll (1s) since the slicer emits stage changes
         # several times per minute on complex models.
-        progress_task: asyncio.Task | None = None
-        if request_id is not None and on_progress is not None:
-            progress_task = asyncio.create_task(
-                self._poll_progress(request_id, on_progress),
-                name=f"slicer-progress-{request_id}",
-            )
-
-        try:
-            response = await self._client.post(
-                f"{self.base_url}/slice",
-                files=files,
-                data=data,
-                timeout=self.timeout_seconds,
-            )
-        except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
-        finally:
-            if progress_task is not None:
-                progress_task.cancel()
-                try:
-                    await progress_task
-                except (asyncio.CancelledError, Exception):
-                    pass  # Polling errors must not fail the slice.
-
+        response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
         return _handle_slice_response(response, export_3mf=export_3mf)
 
     async def slice_without_profiles(
@@ -396,30 +580,7 @@ class SlicerApiService:
         # embedded-settings fallback path triggered by an Orca/Bambu CLI
         # segfault on complex H2D models — both want to keep updating
         # the user's toast through the slow operation.
-        progress_task: asyncio.Task | None = None
-        if request_id is not None and on_progress is not None:
-            progress_task = asyncio.create_task(
-                self._poll_progress(request_id, on_progress),
-                name=f"slicer-progress-{request_id}",
-            )
-
-        try:
-            response = await self._client.post(
-                f"{self.base_url}/slice",
-                files=files,
-                data=data,
-                timeout=self.timeout_seconds,
-            )
-        except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
-        finally:
-            if progress_task is not None:
-                progress_task.cancel()
-                try:
-                    await progress_task
-                except (asyncio.CancelledError, Exception):
-                    pass
-
+        response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
         return _handle_slice_response(response, export_3mf=export_3mf)
 
 

+ 26 - 1
backend/tests/integration/test_library_slice_api.py

@@ -69,6 +69,17 @@ def _install_mock_sidecar(handler: Callable[[httpx.Request], httpx.Response]) ->
     return client
 
 
+def _is_slice_post(request: httpx.Request) -> bool:
+    """True for the slice call itself, false for the progress polls beside it.
+
+    Since #2730 a slice is supervised by a 1 Hz poll of
+    ``GET /slice/progress/{id}``, which shares this mock transport. Tests that
+    count *slice attempts* — primary vs embedded-settings fallback — have to
+    exclude those, or the count becomes a measure of how long the test took.
+    """
+    return request.method == "POST" and request.url.path.endswith("/slice")
+
+
 async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0) -> dict:
     """Poll `/api/v1/slice-jobs/{id}` until the job hits a terminal state.
 
@@ -414,6 +425,8 @@ class TestSliceLibraryFile:
         call_count = {"n": 0}
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             call_count["n"] += 1
             # First call: profile triplet present → simulate CLI 5xx
             if call_count["n"] == 1:
@@ -454,7 +467,9 @@ class TestSliceLibraryFile:
         # STL has no embedded settings — the CLI 5xx is terminal.
         call_count = {"n": 0}
 
-        def handler(_: httpx.Request) -> httpx.Response:
+        def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             call_count["n"] += 1
             return httpx.Response(
                 status_code=500,
@@ -568,6 +583,8 @@ class TestSliceLibraryFile:
         call_count = {"n": 0}
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             call_count["n"] += 1
             captured["body"] = request.content
             return httpx.Response(
@@ -777,6 +794,8 @@ class TestCrossClassSliceAllLoop:
         captured_requests: list[dict] = []
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             # Multipart bodies aren't trivially parseable here; pull
             # the plate field by string search since the helper sends
             # ``name="plate"`` immediately followed by the value.
@@ -1463,6 +1482,8 @@ class TestSliceSlicerRejection:
         call_count = {"n": 0}
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             call_count["n"] += 1
             return httpx.Response(
                 status_code=500,
@@ -1721,6 +1742,8 @@ class TestUnusedSlotSubstitutionOnSinglePlateSource:
         captured: list[list[str]] = []
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             captured.append(self._filament_names_sent(request.content))
             return httpx.Response(
                 status_code=200,
@@ -1818,6 +1841,8 @@ class TestUnusedSlotSubstitutionOnSinglePlateSource:
         captured: list[list[str]] = []
 
         def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
             captured.append(self._filament_names_sent(request.content))
             return httpx.Response(
                 status_code=200,

+ 229 - 0
backend/tests/unit/test_slicer_stall_timeout.py

@@ -0,0 +1,229 @@
+"""Tests for the progress-supervised slice timeout (#2730).
+
+The old behaviour was a flat 300 s httpx timeout on the slice POST. A heavy
+model that Bambu Studio also took a long time over blew through it while the
+slicer was working perfectly happily, and — because ``httpx.ReadTimeout`` is a
+subclass of ``RequestError`` — the failure was reported as "Slicer sidecar
+unreachable", sending the reporter off to check a sidecar that was reachable
+throughout.
+
+The wait is now bounded by *silence* instead: Bambuddy already polls the
+sidecar's progress endpoint once a second, so it can tell a slow slice from a
+stalled one. The deadline moves forward on every progress update.
+"""
+
+import asyncio
+
+import httpx
+import pytest
+
+from backend.app.services.slicer_api import (
+    DEFAULT_SLICE_STALL_TIMEOUT_SECONDS,
+    SlicerApiService,
+    SlicerApiUnavailableError,
+    SlicerTimeoutError,
+    _Liveness,
+    get_stall_timeout_seconds,
+)
+
+SLICE_ARGS = {
+    "model_bytes": b"solid\n",
+    "model_filename": "cube.3mf",
+    "printer_profile_json": "{}",
+    "process_profile_json": "{}",
+    "filament_profile_jsons": ["{}"],
+}
+
+
+def _service(handler, *, timeout_seconds: float, poll_interval: float = 0.02) -> SlicerApiService:
+    """A service wired to a mock sidecar, with the timing compressed.
+
+    The stall window is floored at three poll intervals — liveness can only be
+    observed as fast as the poller ticks — so tests shrink both together rather
+    than waiting out production's 1 Hz.
+    """
+    client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+    svc = SlicerApiService("http://sidecar:3003", client=client, timeout_seconds=timeout_seconds)
+    svc.progress_poll_interval = poll_interval
+    return svc
+
+
+class TestLivenessWindow:
+    """The unit that decides when to stop waiting."""
+
+    def test_a_fresh_slice_has_the_full_window(self):
+        live = _Liveness(60.0, 1.0)
+        assert live.deadline - live.started_at == pytest.approx(60.0)
+
+    def test_progress_pushes_the_deadline_out(self):
+        live = _Liveness(60.0, 1.0)
+        live.saw_progress_endpoint()
+        before = live.deadline
+        live._last_alive += 30.0  # simulate a progress update 30s later
+        assert live.deadline > before
+
+    def test_without_a_progress_channel_the_window_is_total_elapsed(self):
+        """No liveness signal means no way to tell slow from stalled, so the
+        window degrades to the pre-#2730 wall clock — just configurable."""
+        live = _Liveness(60.0, 1.0)
+        live.mark_alive()  # would move the deadline if progress were supported
+        assert live.deadline == pytest.approx(live.started_at + 60.0)
+
+    def test_message_distinguishes_the_two_cases(self):
+        supported = _Liveness(60.0, 1.0)
+        supported.saw_progress_endpoint()
+        assert "stopped reporting progress" in supported.timeout_message()
+
+        unsupported = _Liveness(60.0, 1.0)
+        assert "does not report progress" in unsupported.timeout_message()
+
+    def test_message_points_at_the_setting(self):
+        live = _Liveness(900.0, 1.0)
+        assert "Settings -> Workflow -> Slicer" in live.timeout_message()
+
+
+class TestSliceIsNotCutOffWhileProgressing:
+    @pytest.mark.asyncio
+    async def test_a_slow_slice_that_reports_progress_completes(self):
+        """The reporter's case: slower than the old ceiling, still working.
+
+        The slice takes ~5x the stall window; progress keeps arriving, so it
+        must run to completion rather than being abandoned.
+        """
+        progress = {"n": 0}
+
+        async def handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path.endswith("/slice"):
+                await asyncio.sleep(0.5)
+                return httpx.Response(
+                    200,
+                    content=b"G1 X0\n",
+                    headers={
+                        "x-print-time-seconds": "100",
+                        "x-filament-used-g": "1.0",
+                        "x-filament-used-mm": "100",
+                    },
+                )
+            progress["n"] += 1
+            return httpx.Response(200, json={"percent": progress["n"]})
+
+        svc = _service(handler, timeout_seconds=0.1)
+        result = await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-1", on_progress=lambda _p: None)
+
+        assert result.print_time_seconds == 100
+        assert progress["n"] > 1, "the poller must have been running throughout"
+
+    @pytest.mark.asyncio
+    async def test_repeated_identical_progress_does_not_count_as_alive(self):
+        """The sidecar re-serves its last snapshot on every poll. Treating that
+        as progress would make a stall undetectable."""
+
+        async def handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path.endswith("/slice"):
+                await asyncio.sleep(10)
+                return httpx.Response(200, content=b"never gets here")
+            return httpx.Response(200, json={"percent": 42})  # frozen
+
+        svc = _service(handler, timeout_seconds=0.3)
+        with pytest.raises(SlicerTimeoutError):
+            await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-2", on_progress=lambda _p: None)
+
+
+class TestStalledSliceFails:
+    @pytest.mark.asyncio
+    async def test_silence_ends_the_wait(self):
+        async def handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path.endswith("/slice"):
+                await asyncio.sleep(10)
+                return httpx.Response(200, content=b"never gets here")
+            return httpx.Response(404)  # no progress available
+
+        svc = _service(handler, timeout_seconds=0.2)
+        with pytest.raises(SlicerTimeoutError) as exc:
+            await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-3", on_progress=lambda _p: None)
+
+        assert "does not report progress" in str(exc.value)
+
+    @pytest.mark.asyncio
+    async def test_timeout_is_not_reported_as_unreachable(self):
+        """The whole point: this used to surface as "Slicer sidecar unreachable"."""
+
+        async def handler(request: httpx.Request) -> httpx.Response:
+            if request.url.path.endswith("/slice"):
+                await asyncio.sleep(10)
+            return httpx.Response(404)
+
+        svc = _service(handler, timeout_seconds=0.2)
+        with pytest.raises(SlicerTimeoutError) as exc:
+            await svc.slice_with_profiles(**SLICE_ARGS, request_id="req-4", on_progress=lambda _p: None)
+
+        assert not isinstance(exc.value, SlicerApiUnavailableError)
+        assert "unreachable" not in str(exc.value)
+
+    @pytest.mark.asyncio
+    async def test_a_genuinely_unreachable_sidecar_still_says_so(self):
+        """Timeouts got their own type; connection failures keep the old one."""
+
+        async def handler(_request: httpx.Request) -> httpx.Response:
+            raise httpx.ConnectError("connection refused")
+
+        svc = _service(handler, timeout_seconds=5.0)
+        with pytest.raises(SlicerApiUnavailableError) as exc:
+            await svc.slice_with_profiles(**SLICE_ARGS)
+
+        assert "unreachable" in str(exc.value)
+
+
+class TestStallTimeoutSetting:
+    @pytest.mark.asyncio
+    async def test_reads_the_configured_value(self):
+        class _DB:
+            pass
+
+        async def fake_get_setting(_db, key):
+            assert key == "slicer_stall_timeout_minutes"
+            return "45"
+
+        import backend.app.api.routes.settings as settings_module
+
+        original = settings_module.get_setting
+        settings_module.get_setting = fake_get_setting
+        try:
+            assert await get_stall_timeout_seconds(_DB()) == 45 * 60
+        finally:
+            settings_module.get_setting = original
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("stored", [None, "", "not-a-number", "0", "-5"])
+    async def test_falls_back_rather_than_failing_the_slice(self, stored):
+        """A bad settings row must not be the reason a print doesn't happen."""
+
+        async def fake_get_setting(_db, _key):
+            return stored
+
+        import backend.app.api.routes.settings as settings_module
+
+        original = settings_module.get_setting
+        settings_module.get_setting = fake_get_setting
+        try:
+            assert await get_stall_timeout_seconds(object()) == DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
+        finally:
+            settings_module.get_setting = original
+
+    @pytest.mark.asyncio
+    async def test_a_failing_lookup_falls_back_too(self):
+        async def boom(_db, _key):
+            raise RuntimeError("db is down")
+
+        import backend.app.api.routes.settings as settings_module
+
+        original = settings_module.get_setting
+        settings_module.get_setting = boom
+        try:
+            assert await get_stall_timeout_seconds(object()) == DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
+        finally:
+            settings_module.get_setting = original
+
+    def test_default_is_longer_than_the_old_fixed_ceiling(self):
+        """300s was the number that broke; the new default must beat it."""
+        assert DEFAULT_SLICE_STALL_TIMEOUT_SECONDS > 300

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

@@ -1278,6 +1278,10 @@ export interface AppSettings {
   // Per-install sidecar URLs. Empty string falls back to the env defaults.
   orcaslicer_api_url: string;
   bambu_studio_api_url: string;
+  // Minutes of silence from the sidecar before a slice is abandoned. Bounds
+  // stalls, not total slicing time — a model that keeps reporting progress
+  // runs to completion however long it takes.
+  slicer_stall_timeout_minutes: number;
   // Prometheus metrics
   prometheus_enabled: boolean;
   prometheus_token: string;

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

@@ -2237,6 +2237,8 @@ export default {
     slicerCard: 'Slicer',
     orcaslicerApiUrl: 'OrcaSlicer Sidecar-URL',
     bambuStudioApiUrl: 'Bambu Studio Sidecar-URL',
+    slicerStallTimeout: 'Zeitlimit bei Slicer-Stillstand (Minuten)',
+    slicerStallTimeoutDescription: 'Bricht einen Slice-Vorgang ab, wenn der Sidecar so lange keinen Fortschritt meldet. Aufwendige Modelle, die weiter Fortschritt melden, werden nie abgebrochen, egal wie lange sie brauchen. Sidecars ohne Fortschrittsmeldung nutzen diesen Wert stattdessen als Gesamtzeitlimit.',
     slicerApiUrlDescription: 'URL des Slicer-API-Sidecar-Containers. Leer lassen, um die SLICER_API_URL- bzw. BAMBU_STUDIO_API_URL-Umgebungsvariablen zu nutzen.',
     slicerBundlesRemoved: {
       title: 'Slicer-Bundles (entfernt)',

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

@@ -2256,6 +2256,8 @@ export default {
     slicerCard: 'Slicer',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: 'Slicer stall timeout (minutes)',
+    slicerStallTimeoutDescription: 'Give up on a slice after this long with no progress from the sidecar. Heavy models that keep reporting progress are never cut off, however long they take. Sidecars that do not report progress use this as a total time limit instead.',
     slicerApiUrlDescription: 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
     slicerBundlesRemoved: {
       title: 'Slicer Bundles (removed)',

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

@@ -2240,6 +2240,8 @@ export default {
     slicerCard: 'Laminador',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: 'Tiempo de espera por inactividad del laminador (minutos)',
+    slicerStallTimeoutDescription: 'Abandona un laminado tras este tiempo sin progreso del sidecar. Los modelos pesados que siguen informando progreso nunca se interrumpen, por mucho que tarden. Los sidecars que no informan progreso usan este valor como limite de tiempo total.',
     slicerApiUrlDescription: 'URL del contenedor auxiliar de la API del laminador. Déjelo en blanco para usar los valores predeterminados de las variables de entorno SLICER_API_URL / BAMBU_STUDIO_API_URL.',
     slicerBundlesRemoved: {
       title: 'Paquetes del laminador (eliminado)',

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

@@ -2193,6 +2193,8 @@ export default {
     slicerCard: 'Slicer',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: "Delai d'inactivite du trancheur (minutes)",
+    slicerStallTimeoutDescription: 'Abandonne un decoupage apres cette duree sans progression du sidecar. Les modeles lourds qui continuent a signaler leur progression ne sont jamais interrompus, quel que soit le temps necessaire. Les sidecars qui ne signalent pas de progression utilisent cette valeur comme limite de duree totale.',
     slicerApiUrlDescription: 'URL du conteneur sidecar slicer-API. Laisser vide pour utiliser les variables d\'environnement SLICER_API_URL / BAMBU_STUDIO_API_URL.',
     slicerBundlesRemoved: {
       title: 'Bundles de slicer (supprimé)',

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

@@ -2193,6 +2193,8 @@ export default {
     slicerCard: 'Slicer',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: 'Timeout di inattivita dello slicer (minuti)',
+    slicerStallTimeoutDescription: 'Interrompe uno slice dopo questo tempo senza progressi dal sidecar. I modelli pesanti che continuano a segnalare progressi non vengono mai interrotti, per quanto tempo richiedano. I sidecar che non segnalano progressi usano questo valore come limite di tempo totale.',
     slicerApiUrlDescription: 'URL del container sidecar slicer-API. Lascia vuoto per usare le variabili d\'ambiente SLICER_API_URL / BAMBU_STUDIO_API_URL.',
     slicerBundlesRemoved: {
       title: 'Bundle slicer (rimosso)',

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

@@ -2236,6 +2236,8 @@ export default {
     slicerCard: 'スライサー',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: 'スライサー停止タイムアウト(分)',
+    slicerStallTimeoutDescription: 'サイドカーからの進捗がこの時間なければスライスを中止します。進捗を報告し続ける重いモデルは、どれだけ時間がかかっても中断されません。進捗を報告しないサイドカーでは、この値が合計時間の上限になります。',
     slicerApiUrlDescription: 'slicer-APIサイドカーコンテナのURL。空のままにすると SLICER_API_URL / BAMBU_STUDIO_API_URL 環境変数のデフォルト値が使用されます。',
     slicerBundlesRemoved: {
       title: 'スライサーバンドル(削除済み)',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -2110,6 +2110,8 @@ export default {
     slicerCard: '슬라이서',
     orcaslicerApiUrl: 'OrcaSlicer 사이드카 URL',
     bambuStudioApiUrl: 'Bambu Studio 사이드카 URL',
+    slicerStallTimeout: '슬라이서 정지 시간 제한(분)',
+    slicerStallTimeoutDescription: '사이드카에서 이 시간 동안 진행 상황이 없으면 슬라이싱을 중단합니다. 진행 상황을 계속 보고하는 무거운 모델은 아무리 오래 걸려도 중단되지 않습니다. 진행 상황을 보고하지 않는 사이드카에서는 이 값이 전체 시간 제한으로 사용됩니다.',
     slicerApiUrlDescription: '슬라이서 API 사이드카 컨테이너의 URL. SLICER_API_URL / BAMBU_STUDIO_API_URL 환경 변수 기본값을 사용하려면 비워두세요.',
     slicerBundlesRemoved: {
       title: '슬라이서 번들 (제거됨)',

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

@@ -2193,6 +2193,8 @@ export default {
     slicerCard: 'Fatiador',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: 'Tempo limite de inatividade do fatiador (minutos)',
+    slicerStallTimeoutDescription: 'Desiste de um fatiamento apos esse tempo sem progresso do sidecar. Modelos pesados que continuam relatando progresso nunca sao interrompidos, por mais que demorem. Sidecars que nao relatam progresso usam este valor como limite de tempo total.',
     slicerApiUrlDescription: 'URL do contêiner sidecar slicer-API. Deixe em branco para usar SLICER_API_URL / BAMBU_STUDIO_API_URL.',
     slicerBundlesRemoved: {
       title: 'Bundles do fatiador (removido)',

+ 2 - 0
frontend/src/i18n/locales/ru.ts

@@ -2111,6 +2111,8 @@ export default {
     slicerCard: "Слайсер",
     orcaslicerApiUrl: "URL API-службы OrcaSlicer",
     bambuStudioApiUrl: "URL API-службы Bambu Studio",
+    slicerStallTimeout: 'Тайм-аут простоя слайсера (минуты)',
+    slicerStallTimeoutDescription: 'Прервать нарезку, если sidecar не сообщает о прогрессе в течение этого времени. Тяжёлые модели, которые продолжают сообщать о прогрессе, не прерываются, сколько бы времени ни потребовалось. Для sidecar без отчёта о прогрессе это значение используется как общий лимит времени.',
     slicerApiUrlDescription: "URL контейнера API-службы слайсера. Оставьте пустым, чтобы использовать значения переменных окружения SLICER_API_URL или BAMBU_STUDIO_API_URL.",
     slicerBundlesRemoved: {
       title: "Пакеты профилей слайсера (удалено)",

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -2241,6 +2241,8 @@ export default {
     slicerCard: 'Dilimleyici',
     orcaslicerApiUrl: 'OrcaSlicer yardımcı bileşen URL',
     bambuStudioApiUrl: 'Bambu Studio yardımcı bileşen URL',
+    slicerStallTimeout: 'Dilimleyici duraklama zaman asimi (dakika)',
+    slicerStallTimeoutDescription: 'Sidecar bu sure boyunca ilerleme bildirmezse dilimleme iptal edilir. Ilerleme bildirmeye devam eden agir modeller ne kadar surerse sursun kesilmez. Ilerleme bildirmeyen sidecar surumleri bu degeri toplam sure siniri olarak kullanir.',
     slicerApiUrlDescription: 'Dilimleyici-API yardımcı bileşen konteynerinin URL\'si. SLICER_API_URL / BAMBU_STUDIO_API_URL ortam değişkeni varsayılanlarını kullanmak için boş bırakın.',
     slicerBundlesRemoved: {
       title: 'Dilimleyici Paketleri (kaldırıldı)',

+ 2 - 0
frontend/src/i18n/locales/uk.ts

@@ -2256,6 +2256,8 @@ export default {
     slicerCard: "Слайсер",
     orcaslicerApiUrl: "URL допоміжного сервісу OrcaSlicer",
     bambuStudioApiUrl: "URL допоміжного сервісу Bambu Studio",
+    slicerStallTimeout: 'Тайм-аут простою слайсера (хвилини)',
+    slicerStallTimeoutDescription: 'Перервати нарізку, якщо sidecar не повідомляє про прогрес протягом цього часу. Важкі моделі, які продовжують повідомляти про прогрес, ніколи не перериваються, скільки б часу не знадобилося. Для sidecar без звіту про прогрес це значення використовується як загальний ліміт часу.',
     slicerApiUrlDescription: "URL контейнера допоміжного сервісу slicer-API. Залиште поле порожнім, щоб використовувати типові значення зі змінних середовища SLICER_API_URL / BAMBU_STUDIO_API_URL.",
     slicerBundlesRemoved: {
       title: "Пакети профілів слайсера (вилучено)",

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

@@ -2238,6 +2238,8 @@ export default {
     slicerCard: '切片器',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: '切片器停滞超时(分钟)',
+    slicerStallTimeoutDescription: '若 sidecar 在此时长内没有任何进度,则放弃本次切片。持续报告进度的复杂模型无论耗时多久都不会被中断。不报告进度的 sidecar 则将此值作为总时长上限。',
     slicerApiUrlDescription: 'slicer-API sidecar 容器的 URL。留空以使用 SLICER_API_URL / BAMBU_STUDIO_API_URL 环境变量默认值。',
     slicerBundlesRemoved: {
       title: '切片器捆绑包(已移除)',

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

@@ -2238,6 +2238,8 @@ export default {
     slicerCard: '切片器',
     orcaslicerApiUrl: 'OrcaSlicer sidecar URL',
     bambuStudioApiUrl: 'Bambu Studio sidecar URL',
+    slicerStallTimeout: '切片器停滯逾時(分鐘)',
+    slicerStallTimeoutDescription: '若 sidecar 在此時長內沒有任何進度,則放棄本次切片。持續回報進度的複雜模型無論耗時多久都不會被中斷。不回報進度的 sidecar 則將此值作為總時長上限。',
     slicerApiUrlDescription: 'slicer-API sidecar 容器的 URL。留空以使用 SLICER_API_URL / BAMBU_STUDIO_API_URL 環境變數預設值。',
     slicerBundlesRemoved: {
       title: '切片器捆綁包(已移除)',

+ 22 - 0
frontend/src/pages/SettingsPage.tsx

@@ -1009,6 +1009,7 @@ export function SettingsPage() {
       (settings.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
       (settings.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
       (settings.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
+      (settings.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
       (settings.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
       settings.prometheus_enabled !== localSettings.prometheus_enabled ||
       settings.prometheus_token !== localSettings.prometheus_token ||
@@ -1112,6 +1113,7 @@ export function SettingsPage() {
         open_in_slicer: localSettings.open_in_slicer,
         use_slicer_api: localSettings.use_slicer_api,
         orcaslicer_api_url: localSettings.orcaslicer_api_url,
+        slicer_stall_timeout_minutes: localSettings.slicer_stall_timeout_minutes,
         bambu_studio_api_url: localSettings.bambu_studio_api_url,
         prometheus_enabled: localSettings.prometheus_enabled,
         prometheus_token: localSettings.prometheus_token,
@@ -4889,6 +4891,26 @@ export function SettingsPage() {
                   </p>
                 </div>
               )}
+              {(localSettings.use_slicer_api ?? false) && (
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    {t('settings.slicerStallTimeout')}
+                  </label>
+                  <input
+                    type="number"
+                    min={1}
+                    max={240}
+                    value={localSettings.slicer_stall_timeout_minutes ?? 15}
+                    onChange={(e) =>
+                      updateSetting('slicer_stall_timeout_minutes', Number(e.target.value))
+                    }
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                  />
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.slicerStallTimeoutDescription')}
+                  </p>
+                </div>
+              )}
             </CardContent>
           </Card>
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CMvWx2qm.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BZPDldI_.js"></script>
+    <script type="module" crossorigin src="/assets/index-CMvWx2qm.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов