Browse Source

Notifications:
- Separate AMS and AMS-HT notification switches (one per device type)
- Fix notification variables not showing (duration, filament, estimated_time)
- Add fallback values for empty notification variables ("Unknown" instead of blank)

Settings:
- Fix API keys badge count only showing after visiting tab
- Move External Links card to third column above Updates
- Add Release Notes modal for viewing full notes before updating

Statistics:
- Fix filament usage trends not showing (wrong API parameters)
- Move dashboard controls (Hidden, Reset Layout) to header row
- Remove duplicate Reset Layout button

Camera:
- Fix ffmpeg processes not killed when closing webcam window
- Add /camera/stop endpoint with POST support for sendBeacon
- Track active streams and proper cleanup on disconnect

maziggy 5 months ago
parent
commit
1a4f146dd0

+ 107 - 15
backend/app/api/routes/camera.py

@@ -2,9 +2,10 @@
 
 import asyncio
 import logging
+import weakref
 from typing import AsyncGenerator
 
-from fastapi import APIRouter, HTTPException, Depends
+from fastapi import APIRouter, HTTPException, Depends, Request
 from fastapi.responses import StreamingResponse, Response
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
@@ -23,6 +24,9 @@ from backend.app.services.printer_manager import printer_manager
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["camera"])
 
+# Track active ffmpeg processes for cleanup
+_active_streams: dict[str, asyncio.subprocess.Process] = {}
+
 
 async def get_printer_or_404(printer_id: int, db: AsyncSession) -> Printer:
     """Get printer by ID or raise 404."""
@@ -38,6 +42,8 @@ async def generate_mjpeg_stream(
     access_code: str,
     model: str | None,
     fps: int = 10,
+    stream_id: str | None = None,
+    disconnect_event: asyncio.Event | None = None,
 ) -> AsyncGenerator[bytes, None]:
     """Generate MJPEG stream from printer camera using ffmpeg.
 
@@ -74,7 +80,7 @@ async def generate_mjpeg_stream(
         "-"  # Output to stdout
     ]
 
-    logger.info(f"Starting camera stream for {ip_address} using URL: rtsps://bblp:***@{ip_address}:{port}/streaming/live/1")
+    logger.info(f"Starting camera stream for {ip_address} (stream_id={stream_id})")
     logger.debug(f"ffmpeg command: {ffmpeg} ... (url hidden)")
 
     process = None
@@ -85,6 +91,10 @@ async def generate_mjpeg_stream(
             stderr=asyncio.subprocess.PIPE,
         )
 
+        # Track active process for cleanup
+        if stream_id:
+            _active_streams[stream_id] = process
+
         # Give ffmpeg a moment to start and check for immediate failures
         await asyncio.sleep(0.5)
         if process.returncode is not None:
@@ -104,6 +114,11 @@ async def generate_mjpeg_stream(
         jpeg_end = b"\xff\xd9"
 
         while True:
+            # Check if client disconnected
+            if disconnect_event and disconnect_event.is_set():
+                logger.info(f"Client disconnected, stopping stream {stream_id}")
+                break
+
             try:
                 # Read chunk from ffmpeg
                 chunk = await asyncio.wait_for(
@@ -150,7 +165,10 @@ async def generate_mjpeg_stream(
                 logger.warning("Camera stream read timeout")
                 break
             except asyncio.CancelledError:
-                logger.info("Camera stream cancelled")
+                logger.info(f"Camera stream cancelled (stream_id={stream_id})")
+                break
+            except GeneratorExit:
+                logger.info(f"Camera stream generator exit (stream_id={stream_id})")
                 break
 
     except FileNotFoundError:
@@ -160,22 +178,38 @@ async def generate_mjpeg_stream(
             b"Content-Type: text/plain\r\n\r\n"
             b"Error: ffmpeg not installed\r\n"
         )
+    except asyncio.CancelledError:
+        logger.info(f"Camera stream task cancelled (stream_id={stream_id})")
+    except GeneratorExit:
+        logger.info(f"Camera stream generator closed (stream_id={stream_id})")
     except Exception as e:
         logger.exception(f"Camera stream error: {e}")
     finally:
-        if process:
+        # Remove from active streams
+        if stream_id and stream_id in _active_streams:
+            del _active_streams[stream_id]
+
+        if process and process.returncode is None:
+            logger.info(f"Terminating ffmpeg process for stream {stream_id}")
             try:
                 process.terminate()
-                await asyncio.wait_for(process.wait(), timeout=5.0)
-            except Exception:
-                process.kill()
-                await process.wait()
-            logger.info(f"Camera stream stopped for {ip_address}")
+                try:
+                    await asyncio.wait_for(process.wait(), timeout=2.0)
+                except asyncio.TimeoutError:
+                    logger.warning(f"ffmpeg didn't terminate gracefully, killing (stream_id={stream_id})")
+                    process.kill()
+                    await process.wait()
+            except ProcessLookupError:
+                pass  # Process already dead
+            except Exception as e:
+                logger.warning(f"Error terminating ffmpeg: {e}")
+            logger.info(f"Camera stream stopped for {ip_address} (stream_id={stream_id})")
 
 
 @router.get("/{printer_id}/camera/stream")
 async def camera_stream(
     printer_id: int,
+    request: Request,
     fps: int = 10,
     db: AsyncSession = Depends(get_db),
 ):
@@ -188,18 +222,49 @@ async def camera_stream(
         printer_id: Printer ID
         fps: Target frames per second (default: 10, max: 30)
     """
+    import uuid
+
     printer = await get_printer_or_404(printer_id, db)
 
     # Validate FPS
     fps = min(max(fps, 1), 30)
 
+    # Generate unique stream ID for tracking
+    stream_id = f"{printer_id}-{uuid.uuid4().hex[:8]}"
+
+    # Create disconnect event that will be set when client disconnects
+    disconnect_event = asyncio.Event()
+
+    async def stream_with_disconnect_check():
+        """Wrapper generator that monitors for client disconnect."""
+        try:
+            async for chunk in generate_mjpeg_stream(
+                ip_address=printer.ip_address,
+                access_code=printer.access_code,
+                model=printer.model,
+                fps=fps,
+                stream_id=stream_id,
+                disconnect_event=disconnect_event,
+            ):
+                # Check if client is still connected
+                if await request.is_disconnected():
+                    logger.info(f"Client disconnected detected for stream {stream_id}")
+                    disconnect_event.set()
+                    break
+                yield chunk
+        except asyncio.CancelledError:
+            logger.info(f"Stream {stream_id} cancelled")
+            disconnect_event.set()
+        except GeneratorExit:
+            logger.info(f"Stream {stream_id} generator closed")
+            disconnect_event.set()
+        finally:
+            disconnect_event.set()
+            # Give a moment for the inner generator to clean up
+            await asyncio.sleep(0.1)
+
     return StreamingResponse(
-        generate_mjpeg_stream(
-            ip_address=printer.ip_address,
-            access_code=printer.access_code,
-            model=printer.model,
-            fps=fps,
-        ),
+        stream_with_disconnect_check(),
         media_type="multipart/x-mixed-replace; boundary=frame",
         headers={
             "Cache-Control": "no-cache, no-store, must-revalidate",
@@ -209,6 +274,33 @@ async def camera_stream(
     )
 
 
+@router.api_route("/{printer_id}/camera/stop", methods=["GET", "POST"])
+async def stop_camera_stream(printer_id: int):
+    """Stop all active camera streams for a printer.
+
+    This can be called by the frontend when the camera window is closed.
+    Accepts both GET and POST (POST for sendBeacon compatibility).
+    """
+    stopped = 0
+    to_remove = []
+    for stream_id, process in list(_active_streams.items()):
+        if stream_id.startswith(f"{printer_id}-"):
+            to_remove.append(stream_id)
+            if process.returncode is None:
+                try:
+                    process.terminate()
+                    stopped += 1
+                    logger.info(f"Terminated ffmpeg process for stream {stream_id}")
+                except Exception as e:
+                    logger.warning(f"Error stopping stream {stream_id}: {e}")
+
+    for stream_id in to_remove:
+        _active_streams.pop(stream_id, None)
+
+    logger.info(f"Stopped {stopped} camera stream(s) for printer {printer_id}, active streams remaining: {list(_active_streams.keys())}")
+    return {"stopped": stopped}
+
+
 @router.get("/{printer_id}/camera/snapshot")
 async def camera_snapshot(
     printer_id: int,

+ 8 - 2
backend/app/api/routes/notifications.py

@@ -45,9 +45,12 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_printer_error": provider.on_printer_error,
         "on_filament_low": provider.on_filament_low,
         "on_maintenance_due": provider.on_maintenance_due,
-        # AMS environmental alarms
+        # AMS environmental alarms (regular AMS)
         "on_ams_humidity_high": provider.on_ams_humidity_high,
         "on_ams_temperature_high": provider.on_ams_temperature_high,
+        # AMS-HT environmental alarms
+        "on_ams_ht_humidity_high": provider.on_ams_ht_humidity_high,
+        "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
         # Quiet hours
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_start": provider.quiet_hours_start,
@@ -104,9 +107,12 @@ async def create_notification_provider(
         on_printer_error=provider_data.on_printer_error,
         on_filament_low=provider_data.on_filament_low,
         on_maintenance_due=provider_data.on_maintenance_due,
-        # AMS environmental alarms
+        # AMS environmental alarms (regular AMS)
         on_ams_humidity_high=provider_data.on_ams_humidity_high,
         on_ams_temperature_high=provider_data.on_ams_temperature_high,
+        # AMS-HT environmental alarms
+        on_ams_ht_humidity_high=provider_data.on_ams_ht_humidity_high,
+        on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
         # Quiet hours
         quiet_hours_enabled=provider_data.quiet_hours_enabled,
         quiet_hours_start=provider_data.quiet_hours_start,

+ 14 - 0
backend/app/core/database.py

@@ -285,6 +285,20 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add AMS-HT alarm notification columns to notification_providers
+    try:
+        await conn.execute(text(
+            "ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text(
+            "ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 37 - 9
backend/app/main.py

@@ -843,6 +843,7 @@ async def on_print_complete(printer_id: int, data: dict):
     try:
         async with async_session() as db:
             from backend.app.models.printer import Printer
+            from backend.app.models.archive import PrintArchive
             result = await db.execute(
                 select(Printer).where(Printer.id == printer_id)
             )
@@ -850,9 +851,23 @@ async def on_print_complete(printer_id: int, data: dict):
             printer_name = printer.name if printer else f"Printer {printer_id}"
             status = data.get("status", "completed")
 
+            # Fetch archive data for notification variables
+            archive_data = None
+            if archive_id:
+                archive_result = await db.execute(
+                    select(PrintArchive).where(PrintArchive.id == archive_id)
+                )
+                archive = archive_result.scalar_one_or_none()
+                if archive:
+                    archive_data = {
+                        "print_time_seconds": archive.print_time_seconds,
+                        "actual_filament_grams": archive.filament_used_grams,
+                        "failure_reason": archive.failure_reason,
+                    }
+
             # on_print_complete handles all status types: completed, failed, aborted, stopped
             await notification_service.on_print_complete(
-                printer_id, printer_name, status, data, db
+                printer_id, printer_name, status, data, db, archive_data=archive_data
             )
     except Exception as e:
         import logging
@@ -1052,8 +1067,9 @@ async def record_ams_history():
                         db.add(history)
                         recorded_count += 1
 
-                        # Generate AMS label (A, B, C, D or HT-A for AMS-Lite/Hub)
-                        if ams_id >= 128:
+                        # Generate AMS label and determine if it's AMS-HT (A, B, C, D or HT-A for AMS-Lite/Hub)
+                        is_ams_ht = ams_id >= 128
+                        if is_ams_ht:
                             ams_label = f"HT-{chr(65 + (ams_id - 128))}"
                         else:
                             ams_label = f"AMS-{chr(65 + ams_id)}"
@@ -1067,9 +1083,15 @@ async def record_ams_history():
                                 _ams_alarm_cooldown[cooldown_key] = now
                                 logger.info(f"Sending humidity alarm for {printer.name} {ams_label}: {humidity}% > {humidity_threshold}%")
                                 try:
-                                    await notification_service.on_ams_humidity_high(
-                                        printer.id, printer.name, ams_label, humidity, humidity_threshold, db
-                                    )
+                                    # Call different notification method based on AMS type
+                                    if is_ams_ht:
+                                        await notification_service.on_ams_ht_humidity_high(
+                                            printer.id, printer.name, ams_label, humidity, humidity_threshold, db
+                                        )
+                                    else:
+                                        await notification_service.on_ams_humidity_high(
+                                            printer.id, printer.name, ams_label, humidity, humidity_threshold, db
+                                        )
                                 except Exception as e:
                                     logger.warning(f"Failed to send humidity alarm: {e}")
 
@@ -1082,9 +1104,15 @@ async def record_ams_history():
                                 _ams_alarm_cooldown[cooldown_key] = now
                                 logger.info(f"Sending temperature alarm for {printer.name} {ams_label}: {temperature}°C > {temp_threshold}°C")
                                 try:
-                                    await notification_service.on_ams_temperature_high(
-                                        printer.id, printer.name, ams_label, temperature, temp_threshold, db
-                                    )
+                                    # Call different notification method based on AMS type
+                                    if is_ams_ht:
+                                        await notification_service.on_ams_ht_temperature_high(
+                                            printer.id, printer.name, ams_label, temperature, temp_threshold, db
+                                        )
+                                    else:
+                                        await notification_service.on_ams_temperature_high(
+                                            printer.id, printer.name, ams_label, temperature, temp_threshold, db
+                                        )
                                 except Exception as e:
                                     logger.warning(f"Failed to send temperature alarm: {e}")
 

+ 7 - 3
backend/app/models/notification.py

@@ -72,9 +72,13 @@ class NotificationProvider(Base):
     on_filament_low = Column(Boolean, default=False)
     on_maintenance_due = Column(Boolean, default=False)  # Maintenance reminder
 
-    # Event triggers - AMS environmental alarms
-    on_ams_humidity_high = Column(Boolean, default=False)  # Humidity above threshold
-    on_ams_temperature_high = Column(Boolean, default=False)  # Temperature above threshold
+    # Event triggers - AMS environmental alarms (regular AMS with 4 slots)
+    on_ams_humidity_high = Column(Boolean, default=False)  # AMS humidity above threshold
+    on_ams_temperature_high = Column(Boolean, default=False)  # AMS temperature above threshold
+
+    # Event triggers - AMS-HT environmental alarms (single slot heated AMS)
+    on_ams_ht_humidity_high = Column(Boolean, default=False)  # AMS-HT humidity above threshold
+    on_ams_ht_temperature_high = Column(Boolean, default=False)  # AMS-HT temperature above threshold
 
     # Quiet hours (do not disturb)
     quiet_hours_enabled = Column(Boolean, default=False)

+ 10 - 2
backend/app/schemas/notification.py

@@ -40,10 +40,14 @@ class NotificationProviderBase(BaseModel):
     on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
     on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due")
 
-    # Event triggers - AMS environmental alarms
+    # Event triggers - AMS environmental alarms (regular AMS)
     on_ams_humidity_high: bool = Field(default=False, description="Notify when AMS humidity exceeds threshold")
     on_ams_temperature_high: bool = Field(default=False, description="Notify when AMS temperature exceeds threshold")
 
+    # Event triggers - AMS-HT environmental alarms
+    on_ams_ht_humidity_high: bool = Field(default=False, description="Notify when AMS-HT humidity exceeds threshold")
+    on_ams_ht_temperature_high: bool = Field(default=False, description="Notify when AMS-HT temperature exceeds threshold")
+
     # Quiet hours
     quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
     quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
@@ -100,10 +104,14 @@ class NotificationProviderUpdate(BaseModel):
     on_filament_low: bool | None = None
     on_maintenance_due: bool | None = None
 
-    # Event triggers - AMS environmental alarms
+    # Event triggers - AMS environmental alarms (regular AMS)
     on_ams_humidity_high: bool | None = None
     on_ams_temperature_high: bool | None = None
 
+    # Event triggers - AMS-HT environmental alarms
+    on_ams_ht_humidity_high: bool | None = None
+    on_ams_ht_temperature_high: bool | None = None
+
     # Quiet hours
     quiet_hours_enabled: bool | None = None
     quiet_hours_start: str | None = None

+ 59 - 5
backend/app/services/notification_service.py

@@ -626,9 +626,9 @@ class NotificationService:
         variables = {
             "printer": printer_name,
             "filename": filename,
-            "duration": "",
-            "filament_grams": "",
-            "reason": "",
+            "duration": "Unknown",
+            "filament_grams": "Unknown",
+            "reason": "Unknown",
         }
 
         if archive_data:
@@ -639,6 +639,8 @@ class NotificationService:
             if status == "failed" and archive_data.get("failure_reason"):
                 variables["reason"] = archive_data["failure_reason"]
 
+        logger.info(f"on_print_complete variables: {variables}, archive_data: {archive_data}")
+
         logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
         title, message = await self._build_message_from_template(db, event_type, variables)
         await self._send_to_providers(providers, title, message, db, event_type, printer_id, printer_name)
@@ -661,7 +663,7 @@ class NotificationService:
             "printer": printer_name,
             "filename": self._clean_filename(filename),
             "progress": str(progress),
-            "remaining_time": self._format_duration(remaining_time) if remaining_time else "",
+            "remaining_time": self._format_duration(remaining_time) if remaining_time else "Unknown",
         }
 
         title, message = await self._build_message_from_template(db, "print_progress", variables)
@@ -696,7 +698,7 @@ class NotificationService:
         variables = {
             "printer": printer_name,
             "error_type": error_type,
-            "error_detail": error_detail or "",
+            "error_detail": error_detail or "No details available",
         }
 
         title, message = await self._build_message_from_template(db, "printer_error", variables)
@@ -808,6 +810,58 @@ class NotificationService:
         # Alarms always send immediately, bypassing digest mode
         await self._send_to_providers(providers, title, message, db, "ams_temperature_high", printer_id, printer_name, force_immediate=True)
 
+    async def on_ams_ht_humidity_high(
+        self,
+        printer_id: int,
+        printer_name: str,
+        ams_label: str,
+        humidity: float,
+        threshold: float,
+        db: AsyncSession,
+    ):
+        """Handle AMS-HT high humidity alarm event. Always sends immediately (bypasses digest)."""
+        providers = await self._get_providers_for_event(db, "on_ams_ht_humidity_high", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "ams_label": ams_label,
+            "humidity": f"{humidity:.0f}",
+            "threshold": f"{threshold:.0f}",
+        }
+
+        # Use the same template as regular AMS (can create separate templates later if needed)
+        title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
+        # Alarms always send immediately, bypassing digest mode
+        await self._send_to_providers(providers, title, message, db, "ams_ht_humidity_high", printer_id, printer_name, force_immediate=True)
+
+    async def on_ams_ht_temperature_high(
+        self,
+        printer_id: int,
+        printer_name: str,
+        ams_label: str,
+        temperature: float,
+        threshold: float,
+        db: AsyncSession,
+    ):
+        """Handle AMS-HT high temperature alarm event. Always sends immediately (bypasses digest)."""
+        providers = await self._get_providers_for_event(db, "on_ams_ht_temperature_high", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "ams_label": ams_label,
+            "temperature": f"{temperature:.1f}",
+            "threshold": f"{threshold:.1f}",
+        }
+
+        # Use the same template as regular AMS (can create separate templates later if needed)
+        title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
+        # Alarms always send immediately, bypassing digest mode
+        await self._send_to_providers(providers, title, message, db, "ams_ht_temperature_high", printer_id, printer_name, force_immediate=True)
+
     def clear_template_cache(self):
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()

+ 2 - 0
frontend/src/__tests__/components/NotificationProviderCard.test.tsx

@@ -61,6 +61,8 @@ const createMockProvider = (
   on_maintenance_due: false,
   on_ams_humidity_high: false,
   on_ams_temperature_high: false,
+  on_ams_ht_humidity_high: false,
+  on_ams_ht_temperature_high: false,
   quiet_hours_enabled: false,
   quiet_hours_start: null,
   quiet_hours_end: null,

+ 2 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -54,6 +54,8 @@ const mockNotificationProviders = [
     on_maintenance_due: false,
     on_ams_humidity_high: false,
     on_ams_temperature_high: false,
+    on_ams_ht_humidity_high: false,
+    on_ams_ht_temperature_high: false,
     quiet_hours_enabled: false,
     quiet_hours_start: null,
     quiet_hours_end: null,

+ 12 - 3
frontend/src/api/client.ts

@@ -785,9 +785,12 @@ export interface NotificationProvider {
   on_printer_error: boolean;
   on_filament_low: boolean;
   on_maintenance_due: boolean;
-  // AMS environmental alarms
+  // AMS environmental alarms (regular AMS)
   on_ams_humidity_high: boolean;
   on_ams_temperature_high: boolean;
+  // AMS-HT environmental alarms
+  on_ams_ht_humidity_high: boolean;
+  on_ams_ht_temperature_high: boolean;
   // Quiet hours
   quiet_hours_enabled: boolean;
   quiet_hours_start: string | null;
@@ -822,9 +825,12 @@ export interface NotificationProviderCreate {
   on_printer_error?: boolean;
   on_filament_low?: boolean;
   on_maintenance_due?: boolean;
-  // AMS environmental alarms
+  // AMS environmental alarms (regular AMS)
   on_ams_humidity_high?: boolean;
   on_ams_temperature_high?: boolean;
+  // AMS-HT environmental alarms
+  on_ams_ht_humidity_high?: boolean;
+  on_ams_ht_temperature_high?: boolean;
   // Quiet hours
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
@@ -852,9 +858,12 @@ export interface NotificationProviderUpdate {
   on_printer_error?: boolean;
   on_filament_low?: boolean;
   on_maintenance_due?: boolean;
-  // AMS environmental alarms
+  // AMS environmental alarms (regular AMS)
   on_ams_humidity_high?: boolean;
   on_ams_temperature_high?: boolean;
+  // AMS-HT environmental alarms
+  on_ams_ht_humidity_high?: boolean;
+  on_ams_ht_temperature_high?: boolean;
   // Quiet hours
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;

+ 30 - 6
frontend/src/components/Dashboard.tsx

@@ -33,6 +33,12 @@ interface DashboardProps {
   columns?: number;
   hideControls?: boolean;
   onResetLayout?: () => void;
+  renderControls?: (controls: {
+    hiddenCount: number;
+    showHiddenPanel: boolean;
+    setShowHiddenPanel: (show: boolean) => void;
+    resetLayout: () => void;
+  }) => ReactNode;
 }
 
 interface LayoutState {
@@ -126,7 +132,7 @@ function SortableWidget({
   );
 }
 
-export function Dashboard({ widgets, storageKey, columns = 4, hideControls = false, onResetLayout }: DashboardProps) {
+export function Dashboard({ widgets, storageKey, columns = 4, hideControls = false, onResetLayout, renderControls }: DashboardProps) {
   // Build default sizes from widget definitions
   const getDefaultSizes = () => {
     const sizes: Record<string, 1 | 2 | 4> = {};
@@ -161,6 +167,13 @@ export function Dashboard({ widgets, storageKey, columns = 4, hideControls = fal
 
   const [showHiddenPanel, setShowHiddenPanel] = useState(false);
 
+  // Listen for toggle-hidden-panel event from parent
+  useEffect(() => {
+    const handleToggle = () => setShowHiddenPanel(prev => !prev);
+    window.addEventListener('toggle-hidden-panel', handleToggle);
+    return () => window.removeEventListener('toggle-hidden-panel', handleToggle);
+  }, []);
+
   // Save layout to localStorage whenever it changes
   useEffect(() => {
     localStorage.setItem(storageKey, JSON.stringify(layout));
@@ -246,11 +259,26 @@ export function Dashboard({ widgets, storageKey, columns = 4, hideControls = fal
   const visibleWidgets = orderedWidgets.filter((w) => !layout.hidden.includes(w.id));
   const hiddenWidgets = orderedWidgets.filter((w) => layout.hidden.includes(w.id));
 
+  // Render external controls if provided
+  const externalControls = renderControls?.({
+    hiddenCount: hiddenWidgets.length,
+    showHiddenPanel,
+    setShowHiddenPanel,
+    resetLayout,
+  });
+
   return (
     <div className="space-y-4">
+      {/* External controls slot */}
+      {externalControls}
+
       {/* Dashboard Controls */}
-      {!hideControls && (
+      {!hideControls && !renderControls && (
         <div className="flex items-center justify-end gap-2">
+          <Button variant="secondary" size="sm" onClick={resetLayout}>
+            <RotateCcw className="w-4 h-4" />
+            Reset Layout
+          </Button>
           {hiddenWidgets.length > 0 && (
             <Button
               variant="secondary"
@@ -261,10 +289,6 @@ export function Dashboard({ widgets, storageKey, columns = 4, hideControls = fal
               {hiddenWidgets.length} Hidden
             </Button>
           )}
-          <Button variant="secondary" size="sm" onClick={resetLayout}>
-            <RotateCcw className="w-4 h-4" />
-            Reset Layout
-          </Button>
         </div>
       )}
 

+ 40 - 7
frontend/src/components/NotificationProviderCard.tsx

@@ -148,10 +148,16 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
               <span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">Maintenance</span>
             )}
             {provider.on_ams_humidity_high && (
-              <span className="px-2 py-0.5 bg-blue-600/20 text-blue-300 text-xs rounded">Humidity</span>
+              <span className="px-2 py-0.5 bg-blue-600/20 text-blue-300 text-xs rounded">AMS Humidity</span>
             )}
             {provider.on_ams_temperature_high && (
-              <span className="px-2 py-0.5 bg-orange-600/20 text-orange-300 text-xs rounded">Temperature</span>
+              <span className="px-2 py-0.5 bg-orange-600/20 text-orange-300 text-xs rounded">AMS Temp</span>
+            )}
+            {provider.on_ams_ht_humidity_high && (
+              <span className="px-2 py-0.5 bg-cyan-600/20 text-cyan-300 text-xs rounded">AMS-HT Humidity</span>
+            )}
+            {provider.on_ams_ht_temperature_high && (
+              <span className="px-2 py-0.5 bg-amber-600/20 text-amber-300 text-xs rounded">AMS-HT Temp</span>
             )}
             {provider.quiet_hours_enabled && (
               <span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
@@ -323,14 +329,14 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                 </div>
               </div>
 
-              {/* AMS Environmental Alarms */}
+              {/* AMS Environmental Alarms (regular AMS) */}
               <div className="space-y-2">
                 <p className="text-xs text-bambu-gray uppercase tracking-wide">AMS Alarms</p>
 
                 <div className="flex items-center justify-between">
                   <div>
-                    <p className="text-sm text-white">Humidity High</p>
-                    <p className="text-xs text-bambu-gray">Notify when humidity exceeds threshold</p>
+                    <p className="text-sm text-white">AMS Humidity High</p>
+                    <p className="text-xs text-bambu-gray">Regular AMS humidity exceeds threshold</p>
                   </div>
                   <Toggle
                     checked={provider.on_ams_humidity_high ?? false}
@@ -340,8 +346,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
 
                 <div className="flex items-center justify-between">
                   <div>
-                    <p className="text-sm text-white">Temperature High</p>
-                    <p className="text-xs text-bambu-gray">Notify when temperature exceeds threshold</p>
+                    <p className="text-sm text-white">AMS Temperature High</p>
+                    <p className="text-xs text-bambu-gray">Regular AMS temperature exceeds threshold</p>
                   </div>
                   <Toggle
                     checked={provider.on_ams_temperature_high ?? false}
@@ -350,6 +356,33 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                 </div>
               </div>
 
+              {/* AMS-HT Environmental Alarms */}
+              <div className="space-y-2">
+                <p className="text-xs text-bambu-gray uppercase tracking-wide">AMS-HT Alarms</p>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">AMS-HT Humidity High</p>
+                    <p className="text-xs text-bambu-gray">AMS-HT humidity exceeds threshold</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_ams_ht_humidity_high ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_ams_ht_humidity_high: checked })}
+                  />
+                </div>
+
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">AMS-HT Temperature High</p>
+                    <p className="text-xs text-bambu-gray">AMS-HT temperature exceeds threshold</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_ams_ht_temperature_high ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_ams_ht_temperature_high: checked })}
+                  />
+                </div>
+              </div>
+
               {/* Quiet Hours */}
               <div className="space-y-2">
                 <div className="flex items-center justify-between">

+ 46 - 2
frontend/src/pages/CameraPage.tsx

@@ -34,14 +34,42 @@ export function CameraPage() {
     };
   }, [printer]);
 
-  // Cleanup on unmount
+  // Cleanup on unmount - stop the camera stream
   useEffect(() => {
+    const stopUrl = `/api/v1/printers/${id}/camera/stop`;
+
+    // Handle page unload/close with sendBeacon (more reliable than fetch on unload)
+    const handleBeforeUnload = () => {
+      if (id > 0) {
+        navigator.sendBeacon(stopUrl);
+      }
+    };
+
+    // Handle visibility change (tab hidden/closed)
+    const handleVisibilityChange = () => {
+      if (document.visibilityState === 'hidden' && id > 0) {
+        navigator.sendBeacon(stopUrl);
+      }
+    };
+
+    window.addEventListener('beforeunload', handleBeforeUnload);
+    document.addEventListener('visibilitychange', handleVisibilityChange);
+
     return () => {
+      window.removeEventListener('beforeunload', handleBeforeUnload);
+      document.removeEventListener('visibilitychange', handleVisibilityChange);
+
+      // Clear the image source
       if (imgRef.current) {
         imgRef.current.src = '';
       }
+      // Call the stop endpoint to terminate ffmpeg processes
+      if (id > 0) {
+        // Use sendBeacon for reliability during unmount
+        navigator.sendBeacon(stopUrl);
+      }
     };
-  }, []);
+  }, [id]);
 
   // Auto-hide loading after timeout
   useEffect(() => {
@@ -73,6 +101,12 @@ export function CameraPage() {
     setStreamError(false);
   };
 
+  const stopStream = () => {
+    if (id > 0) {
+      fetch(`/api/v1/printers/${id}/camera/stop`).catch(() => {});
+    }
+  };
+
   const switchToMode = (newMode: 'stream' | 'snapshot') => {
     if (streamMode === newMode || transitioning) return;
     setTransitioning(true);
@@ -83,6 +117,11 @@ export function CameraPage() {
       imgRef.current.src = '';
     }
 
+    // Stop any active streams when switching modes
+    if (streamMode === 'stream') {
+      stopStream();
+    }
+
     setTimeout(() => {
       setStreamMode(newMode);
       setImageKey(Date.now());
@@ -100,6 +139,11 @@ export function CameraPage() {
       imgRef.current.src = '';
     }
 
+    // Stop any active streams before refresh
+    if (streamMode === 'stream') {
+      stopStream();
+    }
+
     setTimeout(() => {
       setImageKey(Date.now());
       setTransitioning(false);

+ 75 - 17
frontend/src/pages/SettingsPage.tsx

@@ -51,6 +51,7 @@ export function SettingsPage() {
   const [showBackupModal, setShowBackupModal] = useState(false);
   const [showRestoreModal, setShowRestoreModal] = useState(false);
   const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
+  const [showReleaseNotes, setShowReleaseNotes] = useState(false);
 
   const handleDefaultViewChange = (path: string) => {
     setDefaultViewState(path);
@@ -126,7 +127,6 @@ export function SettingsPage() {
   const { data: apiKeys, isLoading: apiKeysLoading } = useQuery({
     queryKey: ['api-keys'],
     queryFn: api.getAPIKeys,
-    enabled: activeTab === 'apikeys',
   });
 
   const createAPIKeyMutation = useMutation({
@@ -822,12 +822,12 @@ export function SettingsPage() {
           </Card>
 
           <SpoolmanSettings />
-
-          <ExternalLinksSettings />
         </div>
 
         {/* Third Column - Updates */}
         <div className="space-y-6 flex-1 lg:max-w-sm">
+          <ExternalLinksSettings />
+
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">Updates</h2>
@@ -908,23 +908,28 @@ export function SettingsPage() {
                         {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
                           <p className="text-sm text-bambu-gray mt-1">{updateCheck.release_name}</p>
                         )}
+                      </div>
+                      <div className="flex items-center gap-2">
                         {updateCheck.release_notes && (
-                          <p className="text-sm text-bambu-gray mt-2 whitespace-pre-line line-clamp-3">
-                            {updateCheck.release_notes}
-                          </p>
+                          <button
+                            onClick={() => setShowReleaseNotes(true)}
+                            className="text-bambu-gray hover:text-white transition-colors text-sm underline"
+                          >
+                            Release Notes
+                          </button>
+                        )}
+                        {updateCheck.release_url && (
+                          <a
+                            href={updateCheck.release_url}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="text-bambu-gray hover:text-white transition-colors"
+                            title="View release on GitHub"
+                          >
+                            <ExternalLink className="w-4 h-4" />
+                          </a>
                         )}
                       </div>
-                      {updateCheck.release_url && (
-                        <a
-                          href={updateCheck.release_url}
-                          target="_blank"
-                          rel="noopener noreferrer"
-                          className="text-bambu-gray hover:text-white transition-colors"
-                          title="View release on GitHub"
-                        >
-                          <ExternalLink className="w-4 h-4" />
-                        </a>
-                      )}
                     </div>
 
                     {updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (
@@ -1938,6 +1943,59 @@ export function SettingsPage() {
           </Card>
         </div>
       )}
+
+      {/* Release Notes Modal */}
+      {showReleaseNotes && updateCheck?.release_notes && (
+        <div
+          className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+          onClick={() => setShowReleaseNotes(false)}
+        >
+          <Card className="w-full max-w-2xl max-h-[80vh] flex flex-col" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+            <CardHeader className="flex flex-row items-center justify-between shrink-0">
+              <div>
+                <h2 className="text-lg font-semibold text-white">
+                  Release Notes - v{updateCheck.latest_version}
+                </h2>
+                {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
+                  <p className="text-sm text-bambu-gray">{updateCheck.release_name}</p>
+                )}
+              </div>
+              <button
+                onClick={() => setShowReleaseNotes(false)}
+                className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            </CardHeader>
+            <CardContent className="overflow-y-auto flex-1">
+              <pre className="text-sm text-bambu-gray whitespace-pre-wrap font-sans">
+                {updateCheck.release_notes}
+              </pre>
+            </CardContent>
+            <div className="p-4 border-t border-bambu-dark-tertiary shrink-0 flex gap-2">
+              {updateCheck.release_url && (
+                <a
+                  href={updateCheck.release_url}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="flex-1"
+                >
+                  <Button variant="secondary" className="w-full">
+                    <ExternalLink className="w-4 h-4" />
+                    View on GitHub
+                  </Button>
+                </a>
+              )}
+              <Button
+                onClick={() => setShowReleaseNotes(false)}
+                className="flex-1"
+              >
+                Close
+              </Button>
+            </div>
+          </Card>
+        </div>
+      )}
     </div>
   );
 }

+ 53 - 9
frontend/src/pages/StatsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery } from '@tanstack/react-query';
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
 import {
   Package,
   Clock,
@@ -14,6 +14,7 @@ import {
   FileSpreadsheet,
   FileText,
   Loader2,
+  Eye,
   RotateCcw,
 } from 'lucide-react';
 import { Button } from '../components/Button';
@@ -393,6 +394,31 @@ export function StatsPage() {
   const [isExporting, setIsExporting] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [dashboardKey, setDashboardKey] = useState(0);
+  const [hiddenCount, setHiddenCount] = useState(0);
+
+  // Read hidden count from localStorage
+  useEffect(() => {
+    const updateHiddenCount = () => {
+      try {
+        const saved = localStorage.getItem('bambusy-dashboard-layout');
+        if (saved) {
+          const layout = JSON.parse(saved);
+          setHiddenCount(layout.hidden?.length || 0);
+        }
+      } catch {
+        setHiddenCount(0);
+      }
+    };
+    updateHiddenCount();
+    // Listen for storage changes
+    window.addEventListener('storage', updateHiddenCount);
+    // Also poll for changes (since storage event doesn't fire for same-tab changes)
+    const interval = setInterval(updateHiddenCount, 500);
+    return () => {
+      window.removeEventListener('storage', updateHiddenCount);
+      clearInterval(interval);
+    };
+  }, [dashboardKey]);
 
   const { data: stats, isLoading } = useQuery({
     queryKey: ['archiveStats'],
@@ -406,7 +432,7 @@ export function StatsPage() {
 
   const { data: archives } = useQuery({
     queryKey: ['archives'],
-    queryFn: () => api.getArchives(undefined, 1000, 0),
+    queryFn: () => api.getArchives(undefined, undefined, 1000, 0),
   });
 
   const { data: settings } = useQuery({
@@ -498,11 +524,6 @@ export function StatsPage() {
     },
   ];
 
-  const handleResetLayout = () => {
-    localStorage.removeItem('bambusy-dashboard-layout');
-    setDashboardKey(prev => prev + 1);
-    showToast('Layout reset');
-  };
 
   return (
     <div className="p-4 md:p-8">
@@ -512,9 +533,27 @@ export function StatsPage() {
           <p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
         </div>
         <div className="flex items-center gap-2">
+          {/* Hidden widgets button - toggles panel in Dashboard */}
+          {hiddenCount > 0 && (
+            <Button
+              variant="secondary"
+              onClick={() => {
+                // Toggle the hidden panel in Dashboard by triggering a custom event
+                window.dispatchEvent(new CustomEvent('toggle-hidden-panel'));
+              }}
+            >
+              <Eye className="w-4 h-4" />
+              {hiddenCount} Hidden
+            </Button>
+          )}
+          {/* Reset Layout */}
           <Button
             variant="secondary"
-            onClick={handleResetLayout}
+            onClick={() => {
+              localStorage.removeItem('bambusy-dashboard-layout');
+              setDashboardKey(prev => prev + 1);
+              showToast('Layout reset');
+            }}
           >
             <RotateCcw className="w-4 h-4" />
             Reset Layout
@@ -555,7 +594,12 @@ export function StatsPage() {
         </div>
       </div>
 
-      <Dashboard key={dashboardKey} widgets={widgets} storageKey="bambusy-dashboard-layout" hideControls />
+      <Dashboard
+        key={dashboardKey}
+        widgets={widgets}
+        storageKey="bambusy-dashboard-layout"
+        hideControls
+      />
     </div>
   );
 }

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


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


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


+ 2 - 2
static/index.html

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

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