Ver código fonte

fix(camera): closing one camera view no longer cuts off the others

/camera/stop skipped its teardown only while fan-out subscribers were
attached, which external cameras never use: one viewer leaving killed
every external RTSP/USB ffmpeg of the printer and the other views froze.
Each view (Cam Wall tile, camera window, Camera page) now names itself
with a random id on its stream and on its stop. A stop with an id ends
that view's own streams at once (also a leaked one, #2675) and any that
sent no frame for 10 s, and leaves other views' live streams running;
without an id (an older frontend) it ends them all as before. The orphan
janitor drops a killed stream's viewer entry; a stream between RTSP
reconnects keeps it.
maziggy 1 semana atrás
pai
commit
cde94f99d3
68 arquivos alterados com 370 adições e 24 exclusões
  1. 1 0
      CHANGELOG.md
  2. 48 5
      backend/app/api/routes/camera.py
  3. 25 0
      backend/tests/integration/test_camera_stream_recheck.py
  4. 166 0
      backend/tests/unit/test_camera_stop_spares_live_external.py
  5. 32 0
      frontend/src/__tests__/components/CameraTile.test.tsx
  6. 28 0
      frontend/src/__tests__/components/EmbeddedCameraViewer.test.tsx
  7. 29 0
      frontend/src/__tests__/pages/CameraPage.test.tsx
  8. 8 5
      frontend/src/components/CameraTile.tsx
  9. 7 4
      frontend/src/components/EmbeddedCameraViewer.tsx
  10. 15 0
      frontend/src/hooks/useCameraViewerId.ts
  11. 7 4
      frontend/src/pages/CameraPage.tsx
  12. 0 0
      static/assets/AdditionalSection-CiCx9P8W.js
  13. 0 0
      static/assets/AmsUnitCard-Dba9vRAU.js
  14. 0 0
      static/assets/ArchivesPage-6354ocEu.js
  15. 0 0
      static/assets/AssignToAmsModal-FRQhqlsr.js
  16. 0 0
      static/assets/CamWallPage-CwYdGZXT.js
  17. 0 0
      static/assets/CameraDiagnoseModal-DHHbnDZt.js
  18. 0 0
      static/assets/CameraPage-DodFm4oh.js
  19. 0 0
      static/assets/CameraPage-DvMFfXde.js
  20. 0 0
      static/assets/CameraWall-BwbhN-jt.js
  21. 0 0
      static/assets/CameraWall-Cx-yAK74.js
  22. 0 0
      static/assets/ColumnConfigModal-BhLNCK4L.js
  23. 0 0
      static/assets/ConfigureAmsSlotModal-B82p1Q5P.js
  24. 0 0
      static/assets/ConnectAuthorizePage-BwaaSfvy.js
  25. 0 0
      static/assets/ContextMenu-RAG4LpgW.js
  26. 0 0
      static/assets/ExternalLinkPage-C7MgYBtN.js
  27. 0 1
      static/assets/FileManagerPage-DH4DV3oO.js
  28. 0 0
      static/assets/FileUploadModal-BARAy0iW.js
  29. 0 0
      static/assets/FinancePage-C5IQy0mA.js
  30. 0 0
      static/assets/GCodeViewerPage-BWIEtDsF.js
  31. 0 0
      static/assets/GroupEditPage-Cn_358GJ.js
  32. 0 0
      static/assets/ImagePreviewModal-DuOLw4_p.js
  33. 0 0
      static/assets/InventoryPage-DHfyvQ6x.js
  34. 0 0
      static/assets/LibraryTrashPage-CjmBtYUN.js
  35. 0 0
      static/assets/LoginPage-CXzOz1vf.js
  36. 0 0
      static/assets/MaintenancePage-jfe-ko0E.js
  37. 0 0
      static/assets/ModelSourcesPage-BwhXrAiu.js
  38. 0 0
      static/assets/NotificationsPage-DIul9wz5.js
  39. 0 1
      static/assets/PdfPreviewModal-C8nuv04c.js
  40. 0 0
      static/assets/PhotoGalleryModal-Cr_J3ud1.js
  41. 0 0
      static/assets/PrinterLocationsPage-8vp5CuJC.js
  42. 0 0
      static/assets/PrintersPage-D-BhrkWm.js
  43. 0 0
      static/assets/PrintersPage-DfIFywvM.js
  44. 0 0
      static/assets/ProfilesPage-Dh15KjvZ.js
  45. 0 0
      static/assets/ProjectDetailPage-KhTmn5gk.js
  46. 0 0
      static/assets/ProjectsPage-DAgdlCVT.js
  47. 0 0
      static/assets/QueuePage-Qc8riFq5.js
  48. 0 0
      static/assets/RichTextEditor-Do-B52LP.js
  49. 0 0
      static/assets/SettingsPage-CU-6DXn4.js
  50. 0 0
      static/assets/SetupPage-gVqi13MX.js
  51. 0 0
      static/assets/SliceModal-D1HX6_kE.js
  52. 0 0
      static/assets/SpoolBuddyAmsPage-DD-g7xbl.js
  53. 0 0
      static/assets/SpoolBuddyCalibrationPage-BDV_7bxb.js
  54. 0 0
      static/assets/SpoolBuddyDashboard-xhP9C2mI.js
  55. 0 0
      static/assets/SpoolBuddyInventoryPage-D2spLQWS.js
  56. 1 1
      static/assets/SpoolBuddySettingsPage-CdN-tusl.js
  57. 0 0
      static/assets/SpoolBuddyWriteTagPage-V-iuGh6Y.js
  58. 0 0
      static/assets/SpreadsheetPreviewModal-fa-MfsDR.js
  59. 0 0
      static/assets/StatsPage-DgLLiLPX.js
  60. 0 0
      static/assets/StreamOverlayPage-D_a4iJq8.js
  61. 0 0
      static/assets/SystemInfoPage-B8m1G97E.js
  62. 0 0
      static/assets/index-DPM_Eoz0.js
  63. 1 1
      static/assets/inventorySearch-D_c9vB-F.js
  64. 0 0
      static/assets/locationSensorDefaults-CB23ny6n.js
  65. 0 0
      static/assets/pdf-BsdXxdXS.js
  66. 1 1
      static/assets/useCameraViewerId-DMmJMbGT.js
  67. 0 0
      static/assets/utils-DQCinKkN.js
  68. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Closing one camera view no longer cuts off the others** — With an external RTSP or USB camera, closing one view of a printer (a Cam Wall tile turning to snapshot, the Camera page, the camera window's refresh) stopped that printer's camera stream for every other open view too, which then froze until it reconnected. Each view now names itself on its stream and its stop: the stop ends that view's own stream (at once, as before, so a USB camera is released when its view closes) and any stream that has stopped sending pictures, and leaves the other views' streams running.
 - **Closing or minimizing a camera view now ends its stream** — The camera window on the Printers page left its stream open when minimized (one more open connection per minimize and expand), and both it and the Camera page closed only the first picture they had shown: after a reconnect or a restart, closing them left the current stream running. An open stream keeps the printer's camera connection busy, keeps the chamber light on when "Turn on chamber light for camera" is on for that printer, and made the "stop camera" request on closing do nothing. Every picture a camera view drops now ends its stream. A reconnect also no longer counts one extra failed attempt toward the reconnect limit.
 - **Cam Wall tiles no longer freeze on their last frame** — When the server ended a live stream (the camera dropped, or a brief database hiccup during the stream's 30-second access check), browsers report nothing, so a Cam Wall tile kept showing its last frame until the page was reloaded. Live tiles now reconnect their stream every minute while the page is visible, so a camera that comes back shows a picture again within a minute. If the camera stays down, the tile shows "No signal" (a printer's built-in camera keeps its last picture until Bambuddy stops retrying it). A tile that leaves live mode (scrolled away, paused) now closes its stream too, instead of leaving the connection open. The streaming overlay, which already renewed its camera every minute, now closes the old connection first: a browser source left running for hours piled up one open camera connection per minute. A single failed access check no longer ends a stream either: it is checked again after 5 seconds, and only a second failure in a row ends it.
 - **SQLite no longer gives a deleted user's, API key's, printer's or archive's number to a new one** — SQLite reuses the highest freed id, so whatever still referred to a deleted user, API key, printer, print archive or camera token could end up referring to the next one created. Earlier fixes cleaned up after each such case; these tables now never reuse an id at all. Existing SQLite databases are converted once at start (the rows, columns and indexes are kept), and ids still referred to (a deleted printer's print history, for example) are skipped. PostgreSQL never reused ids itself; a SQLite backup restored on PostgreSQL (including one restored before this release), or a PostgreSQL backup restored on SQLite, now keeps those ids unused too.

+ 48 - 5
backend/app/api/routes/camera.py

@@ -10,8 +10,9 @@ import time
 import uuid
 import zlib
 from collections.abc import AsyncGenerator
+from typing import Annotated
 
-from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
 from fastapi.responses import Response, StreamingResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -126,6 +127,20 @@ _disconnect_events: dict[str, asyncio.Event] = {}
 # Track last frame time per stream_id (not just per printer_id) for stale detection
 _stream_last_frame_times: dict[str, float] = {}
 
+# The viewer that opened an external camera stream (a random id per viewer,
+# sent with its stream and its /camera/stop): that viewer's stop ends its own
+# streams and leaves the other viewers' running. External cameras do not use the
+# fan-out, so the subscriber guard cannot see their other viewers.
+_stream_viewers: dict[str, str] = {}
+
+# A /camera/stop with a viewer id also ends any external stream that sent no
+# frame for this long, whoever opened it (stuck, or orphaned by a skipped
+# cleanup, #2675). Same threshold as /camera/status's "stalled".
+STOP_STALE_EXTERNAL_SECONDS = 10.0
+
+# A viewer id: what the frontend generates, and nothing that could forge a log line.
+ViewerId = Annotated[str | None, Query(pattern=r"^[A-Za-z0-9_-]{1,64}$")]
+
 # How much of a streaming ffmpeg's stderr to retain: enough for the input
 # analysis plus a burst of errors, capped so a long-running stream can't grow it.
 _FFMPEG_STDERR_TAIL_BYTES = 16384
@@ -1009,6 +1024,7 @@ async def camera_stream(
     request: Request,
     fps: int = 10,
     opener: StreamOpener = RequireCameraStreamTokenIfAuthEnabled,
+    viewer: ViewerId = None,
 ):
     """Stream live video from printer camera as MJPEG.
 
@@ -1107,6 +1123,8 @@ async def camera_stream(
                 # Counted here, not in the handler: a body that never starts (the
                 # client left first) never runs the finally that uncounts it.
                 _active_external_streams[printer_id] = _active_external_streams.get(printer_id, 0) + 1
+                if viewer:
+                    _stream_viewers[stream_id] = viewer
                 # Chamber light while the viewer watches (#1655).
                 async with (
                     camera_light(printer, wait=False),
@@ -1144,6 +1162,7 @@ async def camera_stream(
                 _active_streams.pop(stream_id, None)
                 _disconnect_events.pop(stream_id, None)
                 _stream_last_frame_times.pop(stream_id, None)
+                _stream_viewers.pop(stream_id, None)
                 remaining = _active_external_streams.get(printer_id, 0) - 1
                 if remaining > 0:
                     _active_external_streams[printer_id] = remaining
@@ -1269,6 +1288,7 @@ async def camera_stream(
 async def stop_camera_stream(
     printer_id: int,
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    viewer: ViewerId = None,
 ):
     """Stop active camera streams for a printer.
 
@@ -1282,6 +1302,12 @@ async def stop_camera_stream(
     skip the force-teardown — the broadcaster's natural grace-shutdown (5 s
     after subscribers drop to 0) handles cleanup when the leaving viewer's
     HTTP connection actually closes.
+
+    External cameras do not use the fan-out. A viewer passes its id (the one it
+    opened its stream with): its own external streams end at once (also one
+    whose cleanup was skipped, #2675), as does any that sends no frames; other
+    viewers' live streams keep running. Without an id (an older frontend) every
+    stream of the printer ends, as before.
     """
     broadcaster_key = f"printer-{printer_id}"
     remaining_subscribers = get_subscriber_count(broadcaster_key)
@@ -1294,7 +1320,7 @@ async def stop_camera_stream(
         )
         return {"stopped": 0, "skipped": True}
 
-    stopped = await _stop_printer_streams(printer_id)
+    stopped = await _stop_printer_streams(printer_id, viewer=viewer)
     logger.info("Stopped %s camera stream(s) for printer %s", stopped, printer_id)
     return {"stopped": stopped}
 
@@ -1319,10 +1345,17 @@ async def forget_printer(printer_id: int) -> None:
     _active_external_streams.pop(printer_id, None)
 
 
-async def _stop_printer_streams(printer_id: int) -> int:
-    """Stop the fan-out broadcaster and the stream processes of a printer."""
+async def _stop_printer_streams(printer_id: int, *, viewer: str | None = None) -> int:
+    """Stop the fan-out broadcaster and the stream processes of a printer.
+
+    With a viewer (a viewer's /camera/stop), external camera streams other
+    viewers opened and that still send frames are left running; that viewer's
+    own streams end, and so does any external stream that sent nothing for
+    STOP_STALE_EXTERNAL_SECONDS (stuck, or orphaned by a skipped cleanup, #2675).
+    """
     broadcaster_key = f"printer-{printer_id}"
     stopped = 0
+    now = time.time()
 
     # Tear down the fan-out broadcaster first (#1089). This cleanly notifies
     # all subscribed viewers and asks the upstream generator to stop
@@ -1334,6 +1367,12 @@ async def _stop_printer_streams(printer_id: int) -> int:
     to_remove = []
     for stream_id, process in list(_active_streams.items()):
         if stream_id.startswith(f"{printer_id}-"):
+            if viewer is not None and stream_id.startswith(f"{printer_id}-ext-"):
+                last_frame = _stream_last_frame_times.get(stream_id)
+                stale = last_frame is None or now - last_frame >= STOP_STALE_EXTERNAL_SECONDS
+                if _stream_viewers.get(stream_id) != viewer and not stale:
+                    logger.info("Leaving external camera stream %s running: another viewer's", stream_id)
+                    continue
             to_remove.append(stream_id)
             # Signal the generator to stop reconnecting BEFORE killing the process
             event = _disconnect_events.get(stream_id)
@@ -1353,6 +1392,7 @@ async def _stop_printer_streams(printer_id: int) -> int:
         _active_streams.pop(stream_id, None)
         _disconnect_events.pop(stream_id, None)
         _stream_last_frame_times.pop(stream_id, None)
+        _stream_viewers.pop(stream_id, None)
 
     # Stop chamber image streams
     to_remove_chamber = []
@@ -2109,7 +2149,9 @@ async def cleanup_orphaned_streams():
         except (ProcessLookupError, OSError):
             _spawned_ffmpeg_pids.pop(pid, None)
 
-    # 3. Clean up _active_streams entries with dead processes
+    # 3. Clean up _active_streams entries with dead processes. Only these two: a
+    # reconnecting RTSP stream starts its next ffmpeg under the same stream id,
+    # and its disconnect event and viewer must still reach it.
     dead_streams = [sid for sid, proc in _active_streams.items() if proc.returncode is not None]
     for sid in dead_streams:
         proc = _active_streams.pop(sid, None)
@@ -2158,6 +2200,7 @@ async def cleanup_orphaned_streams():
             _active_streams.pop(sid, None)
             _disconnect_events.pop(sid, None)
             _stream_last_frame_times.pop(sid, None)
+            _stream_viewers.pop(sid, None)
             _spawned_ffmpeg_pids.pop(proc.pid, None)
             cleaned += 1
 

+ 25 - 0
backend/tests/integration/test_camera_stream_recheck.py

@@ -399,3 +399,28 @@ async def test_one_viewer_leaving_keeps_the_printers_frame_state_for_the_others(
     await viewer.aclose()
     assert (await camera.camera_status(printer, None))["stream_uptime"] is None
     assert printer not in camera._last_frames
+
+
+async def test_an_external_stream_remembers_the_viewer_that_opened_it(admin, printer):
+    """So that viewer's /camera/stop ends it and leaves the others' running."""
+    token = await auth.create_camera_stream_token(username="camadmin")
+    response = await camera.camera_stream(printer, MagicMock(), fps=5, opener=await _opener(token), viewer="tile-a")
+    stream = response.body_iterator
+    await anext(stream)
+
+    owned = [sid for sid, viewer in camera._stream_viewers.items() if sid.startswith(f"{printer}-ext-")]
+    assert len(owned) == 1 and camera._stream_viewers[owned[0]] == "tile-a"
+
+    await stream.aclose()
+    assert owned[0] not in camera._stream_viewers
+
+
+async def test_a_viewer_id_is_a_short_token(async_client: AsyncClient, admin, printer):
+    login = await async_client.post("/api/v1/auth/login", json={"username": "camadmin", "password": "AdminPass1!"})
+    headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+    bad = await async_client.post(f"/api/v1/printers/{printer}/camera/stop?viewer=a%20b%0Ac", headers=headers)
+    good = await async_client.post(f"/api/v1/printers/{printer}/camera/stop?viewer=k3j9x2ab", headers=headers)
+
+    assert bad.status_code == 422
+    assert good.status_code == 200

+ 166 - 0
backend/tests/unit/test_camera_stop_spares_live_external.py

@@ -0,0 +1,166 @@
+"""/camera/stop ends the leaving viewer's external streams, not the others'.
+
+The stop guard counted only fan-out subscribers, which external cameras never
+use: one viewer leaving (a Cam Wall tile turning to snapshot, the Camera page
+closed, the camera window's Refresh) killed every external RTSP/USB ffmpeg of
+the printer, and the other viewers froze. A viewer now names itself (a random
+id per mount, sent with its stream and its stop): its own streams end at once,
+also one whose cleanup was skipped (#2675), and so does any stream that sends no
+frames; other viewers' live streams keep running. A stop without an id (a
+cached older frontend) still ends them all.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import os
+import time
+
+import pytest
+
+from backend.app.api.routes import camera
+
+PRINTER = 7
+
+
+class _Proc:
+    def __init__(self, pid: int) -> None:
+        self.pid = pid
+        self.returncode = None
+        self.stdout = None
+        self.stderr = None
+
+    def terminate(self) -> None:
+        self.returncode = 0
+
+    def kill(self) -> None:
+        self.returncode = -9
+
+    async def wait(self) -> int:
+        return self.returncode if self.returncode is not None else 0
+
+
+@pytest.fixture
+def no_fanout(monkeypatch):
+    monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
+    monkeypatch.setattr(camera, "get_subscriber_count", lambda _key: 0)
+
+    async def no_broadcaster(_key):
+        return False
+
+    monkeypatch.setattr(camera, "shutdown_broadcaster", no_broadcaster)
+
+
+@pytest.fixture
+def register():
+    added: list[tuple[str, _Proc]] = []
+
+    def _register(suffix: str, pid: int, viewer: str | None, last_frame_age: float | None = 0.5):
+        sid = f"{PRINTER}-ext-{suffix}"
+        proc = _Proc(pid)
+        event = asyncio.Event()
+        camera._active_streams[sid] = proc
+        camera._disconnect_events[sid] = event
+        camera._spawned_ffmpeg_pids[pid] = time.time()
+        if viewer is not None:
+            camera._stream_viewers[sid] = viewer
+        if last_frame_age is not None:
+            camera._stream_last_frame_times[sid] = time.time() - last_frame_age
+        added.append((sid, proc))
+        return proc, event
+
+    yield _register
+    for sid, proc in added:
+        camera._active_streams.pop(sid, None)
+        camera._disconnect_events.pop(sid, None)
+        camera._stream_last_frame_times.pop(sid, None)
+        camera._stream_viewers.pop(sid, None)
+        camera._spawned_ffmpeg_pids.pop(proc.pid, None)
+
+
+@pytest.mark.asyncio
+async def test_a_viewer_leaving_ends_its_own_stream_at_once(no_fanout, register):
+    """#2675: its cleanup may have been skipped, and the stream still looks live."""
+    own, own_event = register("own00001", 54001, viewer="tile-a", last_frame_age=0.1)
+
+    result = await camera.stop_camera_stream(PRINTER, _=None, viewer="tile-a")
+
+    assert result["stopped"] == 1
+    assert own.returncode is not None and own_event.is_set()
+    assert f"{PRINTER}-ext-own00001" not in camera._stream_viewers
+
+
+@pytest.mark.asyncio
+async def test_another_viewers_live_stream_keeps_running(no_fanout, register):
+    own, _ = register("own00002", 54002, viewer="tile-a")
+    other, other_event = register("other002", 54003, viewer="page-b")
+
+    result = await camera.stop_camera_stream(PRINTER, _=None, viewer="tile-a")
+
+    assert result["stopped"] == 1
+    assert own.returncode is not None
+    assert other.returncode is None and not other_event.is_set()
+    assert f"{PRINTER}-ext-other002" in camera._active_streams
+
+
+@pytest.mark.asyncio
+async def test_a_stream_sending_no_frames_ends_whoever_opened_it(no_fanout, register):
+    stuck, _ = register("stuck003", 54004, viewer="page-b", last_frame_age=60.0)
+    never, _ = register("never003", 54005, viewer=None, last_frame_age=None)
+    live, _ = register("live0003", 54006, viewer=None)
+
+    result = await camera.stop_camera_stream(PRINTER, _=None, viewer="tile-a")
+
+    assert result["stopped"] == 2
+    assert stuck.returncode is not None and never.returncode is not None
+    assert live.returncode is None
+
+
+@pytest.mark.asyncio
+async def test_a_stop_without_a_viewer_still_ends_them_all(no_fanout, register):
+    """A cached older frontend sends no id."""
+    a, _ = register("all00004", 54007, viewer="tile-a")
+    b, _ = register("all00005", 54008, viewer="page-b")
+
+    result = await camera.stop_camera_stream(PRINTER, _=None)
+
+    assert result["stopped"] == 2
+    assert a.returncode is not None and b.returncode is not None
+
+
+@pytest.mark.asyncio
+async def test_a_deleted_printer_loses_every_stream(no_fanout, register):
+    live, live_event = register("live0006", 54009, viewer="page-b")
+
+    await camera.forget_printer(PRINTER)
+
+    assert live.returncode is not None and live_event.is_set()
+    assert f"{PRINTER}-ext-live0006" not in camera._stream_viewers
+
+
+@pytest.mark.asyncio
+async def test_the_janitor_drops_the_viewer_of_a_stream_it_kills(no_fanout, register, monkeypatch):
+    """Orphaned by a skipped cleanup and its viewer's stop never came (#2675)."""
+    monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
+    # A real pid, so the janitor's existence check keeps it.
+    stale, stale_event = register("stale007", os.getpid(), viewer="tile-a", last_frame_age=60.0)
+    camera._spawned_ffmpeg_pids[stale.pid] = time.time() - 120
+
+    await camera.cleanup_orphaned_streams()
+
+    assert stale.returncode is not None and stale_event.is_set()
+    assert f"{PRINTER}-ext-stale007" not in camera._stream_viewers
+
+
+@pytest.mark.asyncio
+async def test_the_janitor_keeps_the_viewer_of_a_stream_between_reconnects(no_fanout, register, monkeypatch):
+    """Its ffmpeg died and the stream is starting the next one under the same id:
+    its viewer's stop must still reach it."""
+    monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
+    dead, _ = register("dead0008", 54011, viewer="tile-a")
+    dead.returncode = 1
+
+    await camera.cleanup_orphaned_streams()
+
+    assert camera._stream_viewers.get(f"{PRINTER}-ext-dead0008") == "tile-a"
+    assert f"{PRINTER}-ext-dead0008" in camera._disconnect_events

+ 32 - 0
frontend/src/__tests__/components/CameraTile.test.tsx

@@ -92,6 +92,38 @@ describe('CameraTile', () => {
     expect(screen.queryByAltText('H2D-Booth')).toBeNull();
   });
 
+  // External cameras run one stream per viewer; the stop names this tile's own,
+  // so it ends this tile's stream and not another viewer's of the printer.
+  it('stops its own stream by the viewer id it opened it with', async () => {
+    const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
+    const { rerender } = render(
+      <CameraTile printerId={11} printerName="X1C-Viewer" mode="live" snapshotIntervalMs={5000} connected />,
+    );
+    await flushMicrotasks();
+    const viewer = new URL((screen.getByAltText('X1C-Viewer') as HTMLImageElement).src).searchParams.get('viewer');
+    expect(viewer).toMatch(/^[A-Za-z0-9_-]{1,64}$/);
+
+    await act(async () => {
+      rerender(<CameraTile printerId={11} printerName="X1C-Viewer" mode="snapshot" snapshotIntervalMs={5000} connected />);
+    });
+
+    const stop = fetchMock.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/camera/stop'));
+    expect(new URL(stop!, 'http://x').searchParams.get('viewer')).toBe(viewer);
+  });
+
+  it('gives each tile its own viewer id', async () => {
+    render(
+      <>
+        <CameraTile printerId={11} printerName="Tile-A" mode="live" snapshotIntervalMs={5000} connected />
+        <CameraTile printerId={11} printerName="Tile-B" mode="live" snapshotIntervalMs={5000} connected />
+      </>,
+    );
+    await flushMicrotasks();
+    const id = (name: string) => new URL((screen.getByAltText(name) as HTMLImageElement).src).searchParams.get('viewer');
+
+    expect(id('Tile-A')).not.toBe(id('Tile-B'));
+  });
+
   it('POSTs /camera/stop when leaving live mode', async () => {
     const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue(
       new Response(null, { status: 200 }),

+ 28 - 0
frontend/src/__tests__/components/EmbeddedCameraViewer.test.tsx

@@ -76,6 +76,34 @@ describe('EmbeddedCameraViewer stream lifetime', () => {
     expect(screen.queryByText(/Reconnecting in/)).toBeNull();
   });
 
+  it('stops its own stream by the viewer id it opened it with', async () => {
+    const fetchMock = vi.spyOn(global, 'fetch');
+    const { unmount } = render(viewer());
+    await settle();
+    const id = new URL((screen.getByAltText('Camera stream') as HTMLImageElement).src).searchParams.get('viewer');
+    expect(id).toMatch(/^[A-Za-z0-9_-]{1,64}$/);
+
+    unmount();
+
+    const stop = fetchMock.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/camera/stop'));
+    expect(new URL(stop!, 'http://x').searchParams.get('viewer')).toBe(id);
+  });
+
+  it('names itself on the stop its Refresh sends', async () => {
+    const fetchMock = vi.spyOn(global, 'fetch');
+    render(viewer());
+    await settle();
+    const img = screen.getByAltText('Camera stream');
+    const id = new URL((img as HTMLImageElement).src).searchParams.get('viewer');
+    fireEvent.load(img);
+    await waitFor(() => expect(screen.getByTitle('Refresh stream')).not.toBeDisabled());
+
+    fireEvent.click(screen.getByTitle('Refresh stream'));
+
+    const stop = fetchMock.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/camera/stop'));
+    expect(new URL(stop!, 'http://x').searchParams.get('viewer')).toBe(id);
+  });
+
   it('keeps its stream under StrictMode', async () => {
     render(<StrictMode>{viewer()}</StrictMode>);
     await settle();

+ 29 - 0
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -180,6 +180,35 @@ describe('CameraPage', () => {
       expect(screen.queryByText(/Reconnecting in/)).toBeNull();
     });
 
+    it('stops its own stream by the viewer id it opened it with', async () => {
+      const fetchMock = vi.spyOn(global, 'fetch');
+      const { unmount } = renderCameraPage(1);
+      const img = await streamImg();
+      const id = new URL(img.src).searchParams.get('viewer');
+      expect(id).toMatch(/^[A-Za-z0-9_-]{1,64}$/);
+
+      unmount();
+
+      const stop = fetchMock.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/camera/stop'));
+      expect(new URL(stop!, 'http://x').searchParams.get('viewer')).toBe(id);
+      fetchMock.mockRestore();
+    });
+
+    it('names itself on the stop its Restart sends', async () => {
+      const fetchMock = vi.spyOn(global, 'fetch');
+      renderCameraPage(1);
+      const img = await streamImg();
+      const id = new URL(img.src).searchParams.get('viewer');
+      fireEvent.load(img);
+      await waitFor(() => expect(screen.getByTitle('Restart stream')).not.toBeDisabled());
+
+      fireEvent.click(screen.getByTitle('Restart stream'));
+
+      const stop = fetchMock.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/camera/stop'));
+      expect(new URL(stop!, 'http://x').searchParams.get('viewer')).toBe(id);
+      fetchMock.mockRestore();
+    });
+
     it('keeps its stream under StrictMode', async () => {
       renderCameraPage(1, '', true);
 

+ 8 - 5
frontend/src/components/CameraTile.tsx

@@ -4,6 +4,7 @@ import { AlertTriangle, VideoOff, WifiOff } from 'lucide-react';
 import { getAuthToken, withStreamToken } from '../api/client';
 import { formatDuration } from '../utils/date';
 import { useStreamImgRef } from '../hooks/useStreamImgRef';
+import { useCameraViewerId } from '../hooks/useCameraViewerId';
 
 export type CameraTileMode = 'live' | 'snapshot' | 'paused';
 export type CameraTileStatusMode = 'off' | 'compact' | 'full';
@@ -95,6 +96,8 @@ export function CameraTile({
   // its stream closed; the /camera/stop sent on leaving live mode is skipped
   // while a dropped img still holds it.
   const imgRef = useRef<HTMLImageElement | null>(null);
+  // On its stream and its /camera/stop: the stop ends this tile's stream only.
+  const viewerId = useCameraViewerId();
   const attachImg = useStreamImgRef(imgRef);
   // 'renewed': the current img is a renewal not yet showing a frame; 'retried':
   // it is the one retry of a renewal that failed.
@@ -113,7 +116,7 @@ export function CameraTile({
       const headers: Record<string, string> = {};
       const token = getAuthToken();
       if (token) headers['Authorization'] = `Bearer ${token}`;
-      fetch(`/api/v1/printers/${printerId}/camera/stop`, {
+      fetch(`/api/v1/printers/${printerId}/camera/stop?viewer=${viewerId}`, {
         method: 'POST',
         keepalive: true,
         headers,
@@ -121,7 +124,7 @@ export function CameraTile({
     }
     setErrored(false);
     setBust((b) => b + 1);
-  }, [mode, printerId]);
+  }, [mode, printerId, viewerId]);
 
   useEffect(() => {
     return () => {
@@ -129,14 +132,14 @@ export function CameraTile({
         const headers: Record<string, string> = {};
         const token = getAuthToken();
         if (token) headers['Authorization'] = `Bearer ${token}`;
-        fetch(`/api/v1/printers/${printerId}/camera/stop`, {
+        fetch(`/api/v1/printers/${printerId}/camera/stop?viewer=${viewerId}`, {
           method: 'POST',
           keepalive: true,
           headers,
         }).catch(() => {});
       }
     };
-  }, [printerId]);
+  }, [printerId, viewerId]);
 
   useEffect(() => {
     if (mode !== 'snapshot') return;
@@ -215,7 +218,7 @@ export function CameraTile({
   }, [mode, connected, reopenAfter, clearFirstFrameTimer]);
 
   const liveUrl = withStreamToken(
-    `/api/v1/printers/${printerId}/camera/stream?fps=${LIVE_FPS}&t=${bust}`,
+    `/api/v1/printers/${printerId}/camera/stream?fps=${LIVE_FPS}&t=${bust}&viewer=${viewerId}`,
   );
   const snapshotUrl = withStreamToken(
     `/api/v1/printers/${printerId}/camera/snapshot?t=${bust}`,

+ 7 - 4
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -9,6 +9,7 @@ import { ChamberLight } from './icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
 import { CameraDiagnoseModal } from './CameraDiagnoseModal';
 import { useStreamImgRef } from '../hooks/useStreamImgRef';
+import { useCameraViewerId } from '../hooks/useCameraViewerId';
 
 interface EmbeddedCameraViewerProps {
   printerId: number;
@@ -97,6 +98,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   // Every img the viewer drops (minimized, re-keyed by a reconnect, closed)
   // has its stream closed.
   const imgRef = useRef<HTMLImageElement | null>(null);
+  // On its stream and its /camera/stop: the stop ends this viewer's stream only.
+  const viewerId = useCameraViewerId();
   const attachImg = useStreamImgRef(imgRef);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
   const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -160,7 +163,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   const stopSentRef = useRef(false);
   useEffect(() => {
     stopSentRef.current = false;
-    const stopUrl = `/api/v1/printers/${printerId}/camera/stop`;
+    const stopUrl = `/api/v1/printers/${printerId}/camera/stop?viewer=${viewerId}`;
 
     const sendStopOnce = () => {
       if (printerId > 0 && !stopSentRef.current) {
@@ -178,7 +181,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
       if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
       if (stallCheckIntervalRef.current) clearInterval(stallCheckIntervalRef.current);
     };
-  }, [printerId]);
+  }, [printerId, viewerId]);
 
   // Auto-hide loading after timeout
   useEffect(() => {
@@ -460,7 +463,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     const stopHeaders: Record<string, string> = {};
     const stopToken = getAuthToken();
     if (stopToken) stopHeaders['Authorization'] = `Bearer ${stopToken}`;
-    fetch(`/api/v1/printers/${printerId}/camera/stop`, { method: 'POST', headers: stopHeaders }).catch(() => {});
+    fetch(`/api/v1/printers/${printerId}/camera/stop?viewer=${viewerId}`, { method: 'POST', headers: stopHeaders }).catch(() => {});
 
     if (imgRef.current) imgRef.current.src = '';
     setTimeout(() => setImageKey(Date.now()), 100);
@@ -556,7 +559,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     }
   }, [isDragging, isResizing, dragOffset]);
 
-  const streamUrl = withStreamToken(`/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}`);
+  const streamUrl = withStreamToken(`/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}&viewer=${viewerId}`);
 
   return (
     <div

+ 15 - 0
frontend/src/hooks/useCameraViewerId.ts

@@ -0,0 +1,15 @@
+import { useState } from 'react';
+
+/**
+ * A random id for one camera viewer, stable while it is mounted. It goes on
+ * the viewer's stream URL and on its /camera/stop, so the stop ends that
+ * viewer's own external camera stream and leaves other viewers' running
+ * (external cameras run one stream per viewer, unseen by the fan-out guard).
+ *
+ * Math.random, not crypto.randomUUID: that needs a secure context, and
+ * Bambuddy is often opened over plain http on the LAN.
+ */
+export function useCameraViewerId(): string {
+  const [id] = useState(() => `v${Math.random().toString(36).slice(2, 12)}`);
+  return id;
+}

+ 7 - 4
frontend/src/pages/CameraPage.tsx

@@ -11,6 +11,7 @@ import { ChamberLight } from '../components/icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 import { CameraDiagnoseModal } from '../components/CameraDiagnoseModal';
 import { useStreamImgRef } from '../hooks/useStreamImgRef';
+import { useCameraViewerId } from '../hooks/useCameraViewerId';
 
 const MAX_RECONNECT_ATTEMPTS = 5;
 const INITIAL_RECONNECT_DELAY = 2000; // 2 seconds
@@ -60,6 +61,8 @@ export function CameraPage() {
   // Every img the page drops (re-keyed by a reconnect or mode switch, left)
   // has its stream closed.
   const imgRef = useRef<HTMLImageElement | null>(null);
+  // On its stream and its /camera/stop: the stop ends this page's stream only.
+  const viewerId = useCameraViewerId();
   const attachImg = useStreamImgRef(imgRef);
   const containerRef = useRef<HTMLDivElement>(null);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -127,7 +130,7 @@ export function CameraPage() {
   const stopSentRef = useRef(false);
 
   useEffect(() => {
-    const stopUrl = `/api/v1/printers/${id}/camera/stop`;
+    const stopUrl = `/api/v1/printers/${id}/camera/stop?viewer=${viewerId}`;
     stopSentRef.current = false;
 
     const sendStopOnce = () => {
@@ -152,7 +155,7 @@ export function CameraPage() {
       // Send stop signal only once
       sendStopOnce();
     };
-  }, [id]);
+  }, [id, viewerId]);
 
   // Auto-hide loading after timeout
   useEffect(() => {
@@ -385,7 +388,7 @@ export function CameraPage() {
       const headers: Record<string, string> = {};
       const token = getAuthToken();
       if (token) headers['Authorization'] = `Bearer ${token}`;
-      fetch(`/api/v1/printers/${id}/camera/stop`, { method: 'POST', headers }).catch(() => {});
+      fetch(`/api/v1/printers/${id}/camera/stop?viewer=${viewerId}`, { method: 'POST', headers }).catch(() => {});
     }
   };
 
@@ -631,7 +634,7 @@ export function CameraPage() {
   const currentUrl = transitioning || waitingForStreamToken
     ? ''
     : streamMode === 'stream'
-      ? appendToken(`/api/v1/printers/${id}/camera/stream?fps=${fps}&t=${imageKey}`)
+      ? appendToken(`/api/v1/printers/${id}/camera/stream?fps=${fps}&t=${imageKey}&viewer=${viewerId}`)
       : appendToken(`/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`);
 
   const isDisabled = streamLoading || transitioning || isReconnecting;

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/AdditionalSection-CiCx9P8W.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/AmsUnitCard-Dba9vRAU.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ArchivesPage-6354ocEu.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/AssignToAmsModal-FRQhqlsr.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CamWallPage-CwYdGZXT.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraDiagnoseModal-DHHbnDZt.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraPage-DodFm4oh.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraPage-DvMFfXde.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraWall-BwbhN-jt.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraWall-Cx-yAK74.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ColumnConfigModal-BhLNCK4L.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ConfigureAmsSlotModal-B82p1Q5P.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ConnectAuthorizePage-BwaaSfvy.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ContextMenu-RAG4LpgW.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ExternalLinkPage-C7MgYBtN.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
static/assets/FileManagerPage-DH4DV3oO.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/FileUploadModal-BARAy0iW.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/FinancePage-C5IQy0mA.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/GCodeViewerPage-BWIEtDsF.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/GroupEditPage-Cn_358GJ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ImagePreviewModal-DuOLw4_p.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/InventoryPage-DHfyvQ6x.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/LibraryTrashPage-CjmBtYUN.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/LoginPage-CXzOz1vf.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/MaintenancePage-jfe-ko0E.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ModelSourcesPage-BwhXrAiu.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/NotificationsPage-DIul9wz5.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
static/assets/PdfPreviewModal-C8nuv04c.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PhotoGalleryModal-Cr_J3ud1.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PrinterLocationsPage-8vp5CuJC.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PrintersPage-D-BhrkWm.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PrintersPage-DfIFywvM.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ProfilesPage-Dh15KjvZ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ProjectDetailPage-KhTmn5gk.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ProjectsPage-DAgdlCVT.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/QueuePage-Qc8riFq5.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/RichTextEditor-Do-B52LP.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SettingsPage-CU-6DXn4.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SetupPage-gVqi13MX.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SliceModal-D1HX6_kE.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyAmsPage-DD-g7xbl.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-BDV_7bxb.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyDashboard-xhP9C2mI.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-D2spLQWS.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 1
static/assets/SpoolBuddySettingsPage-CdN-tusl.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-V-iuGh6Y.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpreadsheetPreviewModal-fa-MfsDR.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/StatsPage-DgLLiLPX.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/StreamOverlayPage-D_a4iJq8.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SystemInfoPage-B8m1G97E.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-DPM_Eoz0.js


+ 1 - 1
static/assets/inventorySearch-C5Ldfq0w.js → static/assets/inventorySearch-D_c9vB-F.js

@@ -1 +1 @@
-import{ft as e}from"./index-B-1xu8EB.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-DPM_Eoz0.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/locationSensorDefaults-CB23ny6n.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/pdf-BsdXxdXS.js


+ 1 - 1
static/assets/useStreamImgRef-CRYQNmK0.js → static/assets/useCameraViewerId-DMmJMbGT.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{D as t}from"./client-DnySrlFv.js";var n=e(t(),1);function r(e){return(0,n.useCallback)(t=>{if(e.current=t,t)return()=>{e.current===t&&(e.current=null),queueMicrotask(()=>{t.isConnected||t.removeAttribute(`src`)})}},[e])}export{r as t};
+import{i as e}from"./chunk-aKtaBQYM.js";import{D as t}from"./client-DnySrlFv.js";var n=e(t(),1);function r(e){return(0,n.useCallback)(t=>{if(e.current=t,t)return()=>{e.current===t&&(e.current=null),queueMicrotask(()=>{t.isConnected||t.removeAttribute(`src`)})}},[e])}function i(){let[e]=(0,n.useState)(()=>`v${Math.random().toString(36).slice(2,12)}`);return e}export{r as n,i as t};

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/utils-DQCinKkN.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-B-1xu8EB.js"></script>
+    <script type="module" crossorigin src="/assets/index-DPM_Eoz0.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-DnySrlFv.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-Ce8_sN3w.js">

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff