Przeglądaj źródła

Updated commit message (now includes the test fix):

  Fix camera button permissions & ffmpeg process leak (#550)

  Camera button on printer card was clickable without camera:view
  permission. ffmpeg processes (~240MB each) accumulated after closing
  camera streams because: (1) stop endpoint called terminate() without
  wait()/kill(), (2) HTTP disconnect detection only ran between frames
  so was blocked when the generator was stuck on stdout read, and
  (3) no mechanism caught processes orphaned by generator abandonment
  or app restarts.

  - Add camera:view permission check + tooltip to camera button
  - Fix stop endpoint: terminate() → wait(2s) → kill() → wait()
  - Add background disconnect monitor (polls every 2s, kills ffmpeg
    directly on disconnect)
  - Add periodic /proc scan (every 60s) that SIGKILLs any ffmpeg
    with rtsps://bblp: not in an active stream
  - Add noCamera i18n key to all 6 locales
  - Fix camera API test mocks for async wait() and pid attribute
maziggy 2 miesięcy temu
rodzic
commit
90b9523998

+ 1 - 0
CHANGELOG.md

@@ -15,6 +15,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
 - **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
 
 
 ### Fixed
 ### Fixed
+- **Camera Button Clickable Without Permission & ffmpeg Process Leak** ([#550](https://github.com/maziggy/bambuddy/issues/550)) — Two camera issues in multi-user environments (e.g., classrooms with multiple printers). First, the camera button on the printer card was clickable even when the user's role lacked `camera:view` permission. Now disabled with a permission tooltip, matching the existing pattern for `printers:control` on the chamber light button. Second, ffmpeg processes (~240MB each) were never cleaned up after closing a camera stream. The `stop_camera_stream` endpoint called `terminate()` but never `wait()`ed or `kill()`ed, and HTTP disconnect detection in the streaming response only checked between frames — if the generator was blocked reading from ffmpeg stdout, disconnect was never detected (due to TCP send buffer masking the closed connection). Three fixes: (1) the stop endpoint now uses `terminate()` → `wait(2s)` → `kill()` → `wait()`; (2) each stream gets a background disconnect monitor task that polls `request.is_disconnected()` every 2 seconds independently of the frame loop, directly killing the ffmpeg process on disconnect; (3) a periodic cleanup (every 60s) scans `/proc` for any ffmpeg process with a Bambu RTSP URL (`rtsps://bblp:`) that isn't in an active stream and `SIGKILL`s it — catching orphans that survive app restarts or generator abandonment.
 - **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` launcher had Unix (LF) line endings instead of Windows (CRLF). When a user's git config has `core.autocrlf=false` or `input`, the file is checked out with LF endings and `cmd.exe` cannot parse it. Added a `.gitattributes` file that forces CRLF for all `.bat` files regardless of git config.
 - **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` launcher had Unix (LF) line endings instead of Windows (CRLF). When a user's git config has `core.autocrlf=false` or `input`, the file is checked out with LF endings and `cmd.exe` cannot parse it. Added a `.gitattributes` file that forces CRLF for all `.bat` files regardless of git config.
 - **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
 - **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
 - **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.
 - **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.

+ 164 - 1
backend/app/api/routes/camera.py

@@ -45,6 +45,10 @@ _stream_start_times: dict[int, float] = {}
 # Track active external camera streams by printer ID
 # Track active external camera streams by printer ID
 _active_external_streams: set[int] = set()
 _active_external_streams: set[int] = set()
 
 
+# Track ALL spawned ffmpeg PIDs (persists even if _active_streams entries are removed)
+# Maps PID -> spawn timestamp — used by cleanup to find truly orphaned OS processes
+_spawned_ffmpeg_pids: dict[int, float] = {}
+
 
 
 def get_buffered_frame(printer_id: int) -> bytes | None:
 def get_buffered_frame(printer_id: int) -> bytes | None:
     """Get the last buffered frame for a printer from an active stream.
     """Get the last buffered frame for a printer from an active stream.
@@ -228,6 +232,9 @@ async def generate_rtsp_mjpeg_stream(
         # Track active process for cleanup
         # Track active process for cleanup
         if stream_id:
         if stream_id:
             _active_streams[stream_id] = process
             _active_streams[stream_id] = process
+        import time as _time
+
+        _spawned_ffmpeg_pids[process.pid] = _time.time()
 
 
         # Give ffmpeg a moment to start and check for immediate failures
         # Give ffmpeg a moment to start and check for immediate failures
         await asyncio.sleep(0.5)
         await asyncio.sleep(0.5)
@@ -344,6 +351,9 @@ async def generate_rtsp_mjpeg_stream(
             except OSError as e:
             except OSError as e:
                 logger.warning("Error terminating ffmpeg: %s", e)
                 logger.warning("Error terminating ffmpeg: %s", e)
             logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
             logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
+        # Remove from PID tracking now that process is confirmed dead
+        if process:
+            _spawned_ffmpeg_pids.pop(process.pid, None)
 
 
 
 
 @router.get("/{printer_id}/camera/stream")
 @router.get("/{printer_id}/camera/stream")
@@ -444,6 +454,43 @@ async def camera_stream(
 
 
     _stream_start_times[printer_id] = time.time()
     _stream_start_times[printer_id] = time.time()
 
 
+    async def _kill_stream_process(sid: str):
+        """Terminate+kill the ffmpeg process for a stream ID."""
+        proc = _active_streams.get(sid)
+        if proc and proc.returncode is None:
+            try:
+                proc.terminate()
+                try:
+                    await asyncio.wait_for(proc.wait(), timeout=2.0)
+                except TimeoutError:
+                    proc.kill()
+                    await proc.wait()
+            except (ProcessLookupError, OSError):
+                pass
+
+    async def _monitor_disconnect():
+        """Background task: poll for client disconnect independently of frame loop."""
+        try:
+            while not disconnect_event.is_set():
+                await asyncio.sleep(2)
+                if await request.is_disconnected():
+                    logger.info("Disconnect monitor: client gone (stream %s)", stream_id)
+                    disconnect_event.set()
+                    # Kill ffmpeg process (RTSP streams)
+                    await _kill_stream_process(stream_id)
+                    # Close chamber stream connection if applicable
+                    chamber = _active_chamber_streams.get(stream_id)
+                    if chamber:
+                        try:
+                            chamber[1].close()
+                        except OSError:
+                            pass
+                    break
+        except asyncio.CancelledError:
+            pass
+
+    monitor_task = asyncio.create_task(_monitor_disconnect())
+
     async def stream_with_disconnect_check():
     async def stream_with_disconnect_check():
         """Wrapper generator that monitors for client disconnect."""
         """Wrapper generator that monitors for client disconnect."""
         try:
         try:
@@ -457,7 +504,7 @@ async def camera_stream(
                 printer_id=printer_id,
                 printer_id=printer_id,
             ):
             ):
                 # Check if client is still connected
                 # Check if client is still connected
-                if await request.is_disconnected():
+                if disconnect_event.is_set() or await request.is_disconnected():
                     logger.info("Client disconnected detected for stream %s", stream_id)
                     logger.info("Client disconnected detected for stream %s", stream_id)
                     disconnect_event.set()
                     disconnect_event.set()
                     break
                     break
@@ -470,6 +517,7 @@ async def camera_stream(
             disconnect_event.set()
             disconnect_event.set()
         finally:
         finally:
             disconnect_event.set()
             disconnect_event.set()
+            monitor_task.cancel()
             # Give a moment for the inner generator to clean up
             # Give a moment for the inner generator to clean up
             await asyncio.sleep(0.1)
             await asyncio.sleep(0.1)
 
 
@@ -504,10 +552,19 @@ async def stop_camera_stream(
             if process.returncode is None:
             if process.returncode is None:
                 try:
                 try:
                     process.terminate()
                     process.terminate()
+                    try:
+                        await asyncio.wait_for(process.wait(), timeout=2.0)
+                    except TimeoutError:
+                        logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
+                        process.kill()
+                        await process.wait()
                     stopped += 1
                     stopped += 1
                     logger.info("Terminated ffmpeg process for stream %s", stream_id)
                     logger.info("Terminated ffmpeg process for stream %s", stream_id)
+                except ProcessLookupError:
+                    pass  # Process already dead
                 except OSError as e:
                 except OSError as e:
                     logger.warning("Error stopping stream %s: %s", stream_id, e)
                     logger.warning("Error stopping stream %s: %s", stream_id, e)
+            _spawned_ffmpeg_pids.pop(process.pid, None)
 
 
     for stream_id in to_remove:
     for stream_id in to_remove:
         _active_streams.pop(stream_id, None)
         _active_streams.pop(stream_id, None)
@@ -1086,3 +1143,109 @@ async def delete_reference(
         raise HTTPException(404, "Reference not found")
         raise HTTPException(404, "Reference not found")
 
 
     return {"success": True, "message": "Reference deleted"}
     return {"success": True, "message": "Reference deleted"}
+
+
+def _scan_bambu_ffmpeg_pids() -> list[int]:
+    """Scan /proc for ffmpeg processes with Bambu RTSP URLs.
+
+    These are definitely ours — no other software connects to rtsps://bblp:.
+    This catches orphans that survive app restarts and are not in any tracking dict.
+    """
+    import os
+
+    pids = []
+    try:
+        for entry in os.listdir("/proc"):
+            if not entry.isdigit():
+                continue
+            try:
+                with open(f"/proc/{entry}/cmdline", "rb") as f:
+                    cmdline = f.read()
+                if b"ffmpeg" in cmdline and b"rtsps://bblp:" in cmdline:
+                    pids.append(int(entry))
+            except (OSError, PermissionError, ValueError):
+                continue
+    except OSError:
+        pass
+    return pids
+
+
+async def cleanup_orphaned_streams():
+    """Clean up orphaned ffmpeg processes and stale stream entries.
+
+    Called periodically from the background task loop in main.py.
+
+    Three-layer cleanup:
+    1. /proc scan — finds ALL Bambu ffmpeg processes on the system, even those
+       from previous app sessions. This is the nuclear safety net.
+    2. _spawned_ffmpeg_pids — tracks PIDs spawned this session, catches orphans
+       that were removed from _active_streams but not killed.
+    3. _active_streams — kills stale entries with no recent frames.
+    """
+    import os
+    import signal
+    import time
+
+    cleaned = 0
+    now = time.time()
+
+    # Collect PIDs that are legitimately in-use (active stream, process alive)
+    active_pids = {proc.pid for proc in _active_streams.values() if proc.returncode is None}
+
+    # 1. /proc scan — catch ALL orphaned Bambu ffmpeg processes on the system.
+    #    Any ffmpeg with rtsps://bblp: that is NOT in an active stream is orphaned.
+    for pid in _scan_bambu_ffmpeg_pids():
+        if pid in active_pids:
+            continue
+        logger.info("Killing orphaned ffmpeg process found via /proc (pid=%d)", pid)
+        try:
+            os.kill(pid, signal.SIGKILL)
+        except (ProcessLookupError, OSError):
+            pass
+        _spawned_ffmpeg_pids.pop(pid, None)
+        cleaned += 1
+
+    # 2. Clean up _spawned_ffmpeg_pids entries for dead processes
+    for pid in list(_spawned_ffmpeg_pids):
+        try:
+            os.kill(pid, 0)  # existence check
+        except (ProcessLookupError, OSError):
+            _spawned_ffmpeg_pids.pop(pid, None)
+
+    # 3. Clean up _active_streams entries with dead processes
+    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)
+        if proc:
+            _spawned_ffmpeg_pids.pop(proc.pid, None)
+        cleaned += 1
+
+    # 4. Kill stale active streams (alive but no frames for >60s)
+    for sid, proc in list(_active_streams.items()):
+        if proc.returncode is not None:
+            continue
+        try:
+            printer_id = int(sid.split("-", 1)[0])
+        except (ValueError, IndexError):
+            continue
+        start_time = _stream_start_times.get(printer_id, now)
+        last_frame = _last_frame_times.get(printer_id, start_time)
+        if now - start_time > 120 and now - last_frame > 60:
+            logger.info("Killing stale ffmpeg stream %s (no frames for %.0fs)", sid, now - last_frame)
+            try:
+                proc.kill()
+                await proc.wait()
+            except (ProcessLookupError, OSError):
+                pass
+            _active_streams.pop(sid, None)
+            _spawned_ffmpeg_pids.pop(proc.pid, None)
+            cleaned += 1
+
+    # 4. Clean stale chamber stream entries
+    dead_chamber = [sid for sid, (_reader, writer) in _active_chamber_streams.items() if writer.is_closing()]
+    for sid in dead_chamber:
+        _active_chamber_streams.pop(sid, None)
+        cleaned += 1
+
+    if cleaned:
+        logger.info("Cleaned up %d orphaned camera stream(s)", cleaned)

+ 38 - 0
backend/app/main.py

@@ -3217,6 +3217,40 @@ def stop_spoolbuddy_watchdog():
         logging.getLogger(__name__).info("SpoolBuddy watchdog stopped")
         logging.getLogger(__name__).info("SpoolBuddy watchdog stopped")
 
 
 
 
+# Camera stream orphan cleanup
+_camera_cleanup_task: asyncio.Task | None = None
+CAMERA_CLEANUP_INTERVAL = 60
+
+
+async def _camera_cleanup_loop():
+    """Periodically clean up orphaned ffmpeg processes."""
+    from backend.app.api.routes.camera import cleanup_orphaned_streams
+
+    while True:
+        try:
+            await cleanup_orphaned_streams()
+        except asyncio.CancelledError:
+            break
+        except Exception as e:
+            logging.getLogger(__name__).warning("Camera stream cleanup failed: %s", e)
+        await asyncio.sleep(CAMERA_CLEANUP_INTERVAL)
+
+
+def start_camera_cleanup():
+    global _camera_cleanup_task
+    if _camera_cleanup_task is None:
+        _camera_cleanup_task = asyncio.create_task(_camera_cleanup_loop())
+        logging.getLogger(__name__).info("Camera stream cleanup started")
+
+
+def stop_camera_cleanup():
+    global _camera_cleanup_task
+    if _camera_cleanup_task:
+        _camera_cleanup_task.cancel()
+        _camera_cleanup_task = None
+        logging.getLogger(__name__).info("Camera stream cleanup stopped")
+
+
 @asynccontextmanager
 @asynccontextmanager
 async def lifespan(app: FastAPI):
 async def lifespan(app: FastAPI):
     # Startup
     # Startup
@@ -3326,6 +3360,9 @@ async def lifespan(app: FastAPI):
     # Start SpoolBuddy device watchdog
     # Start SpoolBuddy device watchdog
     start_spoolbuddy_watchdog()
     start_spoolbuddy_watchdog()
 
 
+    # Start camera stream orphan cleanup
+    start_camera_cleanup()
+
     # Initialize virtual printer manager and sync from DB
     # Initialize virtual printer manager and sync from DB
     from backend.app.services.virtual_printer import virtual_printer_manager
     from backend.app.services.virtual_printer import virtual_printer_manager
 
 
@@ -3347,6 +3384,7 @@ async def lifespan(app: FastAPI):
     stop_ams_history_recording()
     stop_ams_history_recording()
     stop_runtime_tracking()
     stop_runtime_tracking()
     stop_spoolbuddy_watchdog()
     stop_spoolbuddy_watchdog()
+    stop_camera_cleanup()
     printer_manager.disconnect_all()
     printer_manager.disconnect_all()
     await close_spoolman_client()
     await close_spoolman_client()
 
 

+ 8 - 2
backend/tests/integration/test_camera_api.py

@@ -59,10 +59,12 @@ class TestCameraAPI:
         """Verify stop terminates active streams for the printer."""
         """Verify stop terminates active streams for the printer."""
         printer = await printer_factory()
         printer = await printer_factory()
 
 
-        # Mock an active stream
+        # Mock an active stream — wait() must be AsyncMock since it's awaited
         mock_process = MagicMock()
         mock_process = MagicMock()
         mock_process.returncode = None
         mock_process.returncode = None
+        mock_process.pid = 99999
         mock_process.terminate = MagicMock()
         mock_process.terminate = MagicMock()
+        mock_process.wait = AsyncMock()
 
 
         with patch("backend.app.api.routes.camera._active_streams", {f"{printer.id}-abc123": mock_process}):
         with patch("backend.app.api.routes.camera._active_streams", {f"{printer.id}-abc123": mock_process}):
             response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
             response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
@@ -78,14 +80,18 @@ class TestCameraAPI:
         printer1 = await printer_factory(name="Printer 1")
         printer1 = await printer_factory(name="Printer 1")
         printer2 = await printer_factory(name="Printer 2")
         printer2 = await printer_factory(name="Printer 2")
 
 
-        # Mock active streams for both printers
+        # Mock active streams for both printers — wait() must be AsyncMock since it's awaited
         mock_process1 = MagicMock()
         mock_process1 = MagicMock()
         mock_process1.returncode = None
         mock_process1.returncode = None
+        mock_process1.pid = 99998
         mock_process1.terminate = MagicMock()
         mock_process1.terminate = MagicMock()
+        mock_process1.wait = AsyncMock()
 
 
         mock_process2 = MagicMock()
         mock_process2 = MagicMock()
         mock_process2.returncode = None
         mock_process2.returncode = None
+        mock_process2.pid = 99997
         mock_process2.terminate = MagicMock()
         mock_process2.terminate = MagicMock()
+        mock_process2.wait = AsyncMock()
 
 
         active_streams = {
         active_streams = {
             f"{printer1.id}-abc123": mock_process1,
             f"{printer1.id}-abc123": mock_process1,

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

@@ -305,6 +305,7 @@ export default {
       noFiles: 'Sie haben keine Berechtigung, auf Druckerdateien zuzugreifen',
       noFiles: 'Sie haben keine Berechtigung, auf Druckerdateien zuzugreifen',
       noAmsRfid: 'Sie haben keine Berechtigung, AMS-RFID erneut zu lesen',
       noAmsRfid: 'Sie haben keine Berechtigung, AMS-RFID erneut zu lesen',
       noSmartPlugControl: 'Sie haben keine Berechtigung, Smart Plugs zu steuern',
       noSmartPlugControl: 'Sie haben keine Berechtigung, Smart Plugs zu steuern',
+      noCamera: 'Sie haben keine Berechtigung, Kameras anzuzeigen',
     },
     },
     // Add/Edit modal
     // Add/Edit modal
     modal: {
     modal: {

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

@@ -305,6 +305,7 @@ export default {
       noFiles: 'You do not have permission to access printer files',
       noFiles: 'You do not have permission to access printer files',
       noAmsRfid: 'You do not have permission to re-read AMS RFID',
       noAmsRfid: 'You do not have permission to re-read AMS RFID',
       noSmartPlugControl: 'You do not have permission to control smart plugs',
       noSmartPlugControl: 'You do not have permission to control smart plugs',
+      noCamera: 'You do not have permission to view cameras',
     },
     },
     // Add/Edit modal
     // Add/Edit modal
     modal: {
     modal: {

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

@@ -305,6 +305,7 @@ export default {
       noFiles: 'Pas d\'autorisation pour les fichiers',
       noFiles: 'Pas d\'autorisation pour les fichiers',
       noAmsRfid: 'Pas d\'autorisation pour le RFID',
       noAmsRfid: 'Pas d\'autorisation pour le RFID',
       noSmartPlugControl: 'Pas d\'autorisation pour les prises',
       noSmartPlugControl: 'Pas d\'autorisation pour les prises',
+      noCamera: 'Pas d\'autorisation pour les caméras',
     },
     },
     // Add/Edit modal
     // Add/Edit modal
     modal: {
     modal: {

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

@@ -296,6 +296,7 @@ export default {
       noFiles: 'Non hai il permesso di accedere ai file stampante',
       noFiles: 'Non hai il permesso di accedere ai file stampante',
       noAmsRfid: 'Non hai il permesso di rileggere AMS RFID',
       noAmsRfid: 'Non hai il permesso di rileggere AMS RFID',
       noSmartPlugControl: 'Non hai il permesso di controllare smart plug',
       noSmartPlugControl: 'Non hai il permesso di controllare smart plug',
+      noCamera: 'Non hai il permesso di visualizzare le telecamere',
     },
     },
     // Add/Edit modal
     // Add/Edit modal
     modal: {
     modal: {

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

@@ -269,6 +269,7 @@ export default {
       noControl: 'プリンターを制御する権限がありません',
       noControl: 'プリンターを制御する権限がありません',
       noAmsRfid: 'AMS RFIDを再読み取りする権限がありません',
       noAmsRfid: 'AMS RFIDを再読み取りする権限がありません',
       noSmartPlugControl: 'スマートプラグを制御する権限がありません',
       noSmartPlugControl: 'スマートプラグを制御する権限がありません',
+      noCamera: 'カメラを表示する権限がありません',
     },
     },
     modal: {
     modal: {
       selectModel: 'モデルを選択...',
       selectModel: 'モデルを選択...',

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

@@ -305,6 +305,7 @@ export default {
       noFiles: 'Você não tem permissão para acessar arquivos de impressora',
       noFiles: 'Você não tem permissão para acessar arquivos de impressora',
       noAmsRfid: 'Você não tem permissão para reler RFID AMS',
       noAmsRfid: 'Você não tem permissão para reler RFID AMS',
       noSmartPlugControl: 'Você não tem permissão para controlar tomadas inteligentes',
       noSmartPlugControl: 'Você não tem permissão para controlar tomadas inteligentes',
+      noCamera: 'Você não tem permissão para visualizar câmeras',
     },
     },
     // Add/Edit modal
     // Add/Edit modal
     modal: {
     modal: {

+ 2 - 2
frontend/src/pages/PrintersPage.tsx

@@ -3610,8 +3610,8 @@ function PrinterCard({
                     window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
                     window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
                   }
                   }
                 }}
                 }}
-                disabled={!status?.connected}
-                title={cameraViewMode === 'embedded' ? t('printers.openCameraOverlay') : t('printers.openCameraWindow')}
+                disabled={!status?.connected || !hasPermission('camera:view')}
+                title={!hasPermission('camera:view') ? t('printers.permission.noCamera') : (cameraViewMode === 'embedded' ? t('printers.openCameraOverlay') : t('printers.openCameraWindow'))}
               >
               >
                 <Video className="w-4 h-4" />
                 <Video className="w-4 h-4" />
               </Button>
               </Button>

Plik diff jest za duży
+ 1 - 0
static/assets/index-BfXsQTz2.js


+ 4 - 0
static/index.html

@@ -24,6 +24,7 @@
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
 <<<<<<< HEAD
 <<<<<<< HEAD
+<<<<<<< HEAD
 <<<<<<< HEAD
 <<<<<<< HEAD
     <script type="module" crossorigin src="/assets/index-CxFtC4Kb.js"></script>
     <script type="module" crossorigin src="/assets/index-CxFtC4Kb.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DJjXosw8.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DJjXosw8.css">
@@ -33,6 +34,9 @@
 >>>>>>> 7eb7bf7 (Fix queue badge showing on printers without matching filament (#486))
 >>>>>>> 7eb7bf7 (Fix queue badge showing on printers without matching filament (#486))
 =======
 =======
     <script type="module" crossorigin src="/assets/index-BawYMb0M.js"></script>
     <script type="module" crossorigin src="/assets/index-BawYMb0M.js"></script>
+=======
+    <script type="module" crossorigin src="/assets/index-BfXsQTz2.js"></script>
+>>>>>>> 1eeeb37 (  Updated commit message (now includes the test fix):)
     <link rel="stylesheet" crossorigin href="/assets/index-BW78djlt.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BW78djlt.css">
 >>>>>>> ce97a47 (  Add H2C dual nozzle variant O1C2 model support (#489))
 >>>>>>> ce97a47 (  Add H2C dual nozzle variant O1C2 model support (#489))
   </head>
   </head>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików