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

Merge pull request #19 from maziggy/0.1.5b

  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

  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

  Documentation:
  - Update README with missing features (camera streaming, AMS/AMS-HT monitoring,
    chamber control, printer control, AI detection, calibration, energy tracking,
    database backup/restore, system info dashboard)
MartinNYHC 9 месяцев назад
Родитель
Сommit
8680113d6a

+ 0 - 0
.github/workflows/ci.yml → .github/workflows.disabled/ci.yml


+ 68 - 9
CHANGELOG.md

@@ -2,17 +2,76 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
-## [Unreleased]
+## [0.1.5b6] - 2025-12-12
 
 
-### Added
-- Mobile PWA (Progressive Web App) support with offline capabilities
-- Playwright end-to-end test suite for comprehensive application testing
-- Filament spool fill levels displayed on printer cards
+  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)
 
 
-### Fixed
-- External links now properly receive keyboard shortcut assignments after reordering
-- External links open in main content area iframe instead of new browser tab
-- Spoolman sync now correctly handles all AMS trays (fixed black filament color bug)
+  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
+
+  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
+
+  Documentation:
+  - Update README with missing features (camera streaming, AMS/AMS-HT monitoring,
+    chamber control, printer control, AI detection, calibration, energy tracking,
+    database backup/restore, system info dashboard)
+
+## [0.1.5b5] - 2025-12-11
+
+### Added
+- Anonymous telemetry system with opt-out support
+- System info page with database and resource statistics
+
+## [0.1.5b4] - 2025-12-11
+
+New Features
+
+    Mobile PWA Support - Progressive Web App support for mobile devices
+    AMS Humidity/Temperature History - Clickable indicators open charts with 6h/24h/48h/7d history, min/max/avg statistics, and threshold reference lines
+    Webhooks & API Keys - API key authentication with granular permissions for external integrations
+    System Info Page - New page showing system information
+    Multi-plate Cover Image - Archive cards now show cover image of the printed plate for multi-plate files
+    Quick Notification Disable - Button to quickly disable notifications
+    Projects / Print Grouping - Group related prints into projects with progress tracking
+    Full-Text Search (FTS5) - Efficient search across print names, filenames, tags, notes, designer, and filament type
+    Failure Analysis - Dashboard widget showing failure rate with correlations and trends
+    Archive Comparison - Compare 2-5 archives side-by-side with highlighted differences
+    CSV/Excel Export - Export archives and statistics with current filters
+
+Improvements
+
+    Improved archive card context menu with submenu support
+    Improved notification scheduler and templates
+    Improved auto power off scheduler
+    Improved email notification provider
+    Configurable AMS data retention (default 30 days)
+
+Bug Fixes
+
+    Fixed bug where not all AMS spools were synced to Spoolman
+    Fixed bug where external links were not respected by hotkeys
+    Fixed context menu submenu not showing
+    Fixed project card thumbnails using correct API endpoint
+    Fixed archive PATCH 500 error (FTS5 index rebuild)
+    Fixed clipboard API fallback for HTTP contexts
+
+Infrastructure
+
+    Added comprehensive automated testing (pytest, vitest, playwright)
+    GitHub Actions CI/CD workflow for automated testing
+    Removed PWA push notifications
 
 
 ## [0.1.5b4] - 2025-12-10
 ## [0.1.5b4] - 2025-12-10
 
 

+ 12 - 2
README.md

@@ -52,7 +52,8 @@
 
 
 ### 📊 Monitoring & Stats
 ### 📊 Monitoring & Stats
 - Real-time printer status via WebSocket
 - Real-time printer status via WebSocket
-- HMS error monitoring
+- Live camera streaming (MJPEG) & snapshots
+- HMS error monitoring with history
 - Print success rates & trends
 - Print success rates & trends
 - Filament usage tracking
 - Filament usage tracking
 - Cost analytics & failure analysis
 - Cost analytics & failure analysis
@@ -62,6 +63,7 @@
 - Print queue with drag-and-drop
 - Print queue with drag-and-drop
 - Scheduled prints (date/time)
 - Scheduled prints (date/time)
 - Smart plug integration (Tasmota)
 - Smart plug integration (Tasmota)
+- Energy consumption tracking
 - Auto power-on before print
 - Auto power-on before print
 - Auto power-off after cooldown
 - Auto power-off after cooldown
 
 
@@ -94,11 +96,19 @@
 - Print time accuracy stats
 - Print time accuracy stats
 - File manager for printer storage
 - File manager for printer storage
 
 
+### 🎛️ Printer Control
+- AMS/AMS-HT temperature & humidity monitoring
+- Chamber temperature & light control
+- Speed profiles & fan controls
+- AI detection modules (spaghetti, first layer)
+- Automated calibration (bed level, vibration)
+- Dual nozzle support
+
 </td>
 </td>
 </tr>
 </tr>
 </table>
 </table>
 
 
-**Plus:** Dark/light theme • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates
+**Plus:** Dark/light theme • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
 
 
 ---
 ---
 
 

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

@@ -2,9 +2,10 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import weakref
 from typing import AsyncGenerator
 from typing import AsyncGenerator
 
 
-from fastapi import APIRouter, HTTPException, Depends
+from fastapi import APIRouter, HTTPException, Depends, Request
 from fastapi.responses import StreamingResponse, Response
 from fastapi.responses import StreamingResponse, Response
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy import select
 from sqlalchemy import select
@@ -23,6 +24,9 @@ from backend.app.services.printer_manager import printer_manager
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["camera"])
 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:
 async def get_printer_or_404(printer_id: int, db: AsyncSession) -> Printer:
     """Get printer by ID or raise 404."""
     """Get printer by ID or raise 404."""
@@ -38,6 +42,8 @@ async def generate_mjpeg_stream(
     access_code: str,
     access_code: str,
     model: str | None,
     model: str | None,
     fps: int = 10,
     fps: int = 10,
+    stream_id: str | None = None,
+    disconnect_event: asyncio.Event | None = None,
 ) -> AsyncGenerator[bytes, None]:
 ) -> AsyncGenerator[bytes, None]:
     """Generate MJPEG stream from printer camera using ffmpeg.
     """Generate MJPEG stream from printer camera using ffmpeg.
 
 
@@ -74,7 +80,7 @@ async def generate_mjpeg_stream(
         "-"  # Output to stdout
         "-"  # 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)")
     logger.debug(f"ffmpeg command: {ffmpeg} ... (url hidden)")
 
 
     process = None
     process = None
@@ -85,6 +91,10 @@ async def generate_mjpeg_stream(
             stderr=asyncio.subprocess.PIPE,
             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
         # Give ffmpeg a moment to start and check for immediate failures
         await asyncio.sleep(0.5)
         await asyncio.sleep(0.5)
         if process.returncode is not None:
         if process.returncode is not None:
@@ -104,6 +114,11 @@ async def generate_mjpeg_stream(
         jpeg_end = b"\xff\xd9"
         jpeg_end = b"\xff\xd9"
 
 
         while True:
         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:
             try:
                 # Read chunk from ffmpeg
                 # Read chunk from ffmpeg
                 chunk = await asyncio.wait_for(
                 chunk = await asyncio.wait_for(
@@ -150,7 +165,10 @@ async def generate_mjpeg_stream(
                 logger.warning("Camera stream read timeout")
                 logger.warning("Camera stream read timeout")
                 break
                 break
             except asyncio.CancelledError:
             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
                 break
 
 
     except FileNotFoundError:
     except FileNotFoundError:
@@ -160,22 +178,38 @@ async def generate_mjpeg_stream(
             b"Content-Type: text/plain\r\n\r\n"
             b"Content-Type: text/plain\r\n\r\n"
             b"Error: ffmpeg not installed\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:
     except Exception as e:
         logger.exception(f"Camera stream error: {e}")
         logger.exception(f"Camera stream error: {e}")
     finally:
     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:
             try:
                 process.terminate()
                 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")
 @router.get("/{printer_id}/camera/stream")
 async def camera_stream(
 async def camera_stream(
     printer_id: int,
     printer_id: int,
+    request: Request,
     fps: int = 10,
     fps: int = 10,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
@@ -188,18 +222,49 @@ async def camera_stream(
         printer_id: Printer ID
         printer_id: Printer ID
         fps: Target frames per second (default: 10, max: 30)
         fps: Target frames per second (default: 10, max: 30)
     """
     """
+    import uuid
+
     printer = await get_printer_or_404(printer_id, db)
     printer = await get_printer_or_404(printer_id, db)
 
 
     # Validate FPS
     # Validate FPS
     fps = min(max(fps, 1), 30)
     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(
     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",
         media_type="multipart/x-mixed-replace; boundary=frame",
         headers={
         headers={
             "Cache-Control": "no-cache, no-store, must-revalidate",
             "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")
 @router.get("/{printer_id}/camera/snapshot")
 async def camera_snapshot(
 async def camera_snapshot(
     printer_id: int,
     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_printer_error": provider.on_printer_error,
         "on_filament_low": provider.on_filament_low,
         "on_filament_low": provider.on_filament_low,
         "on_maintenance_due": provider.on_maintenance_due,
         "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_humidity_high": provider.on_ams_humidity_high,
         "on_ams_temperature_high": provider.on_ams_temperature_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
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_start": provider.quiet_hours_start,
         "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_printer_error=provider_data.on_printer_error,
         on_filament_low=provider_data.on_filament_low,
         on_filament_low=provider_data.on_filament_low,
         on_maintenance_due=provider_data.on_maintenance_due,
         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_humidity_high=provider_data.on_ams_humidity_high,
         on_ams_temperature_high=provider_data.on_ams_temperature_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
         quiet_hours_enabled=provider_data.quiet_hours_enabled,
         quiet_hours_enabled=provider_data.quiet_hours_enabled,
         quiet_hours_start=provider_data.quiet_hours_start,
         quiet_hours_start=provider_data.quiet_hours_start,

+ 1 - 1
backend/app/core/config.py

@@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings
 import logging
 import logging
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.1.5b5"
+APP_VERSION = "0.1.5b6"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 
 
 # Base directory for path calculations
 # Base directory for path calculations

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

@@ -285,6 +285,20 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         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():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """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:
     try:
         async with async_session() as db:
         async with async_session() as db:
             from backend.app.models.printer import Printer
             from backend.app.models.printer import Printer
+            from backend.app.models.archive import PrintArchive
             result = await db.execute(
             result = await db.execute(
                 select(Printer).where(Printer.id == printer_id)
                 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}"
             printer_name = printer.name if printer else f"Printer {printer_id}"
             status = data.get("status", "completed")
             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
             # on_print_complete handles all status types: completed, failed, aborted, stopped
             await notification_service.on_print_complete(
             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:
     except Exception as e:
         import logging
         import logging
@@ -1052,8 +1067,9 @@ async def record_ams_history():
                         db.add(history)
                         db.add(history)
                         recorded_count += 1
                         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))}"
                             ams_label = f"HT-{chr(65 + (ams_id - 128))}"
                         else:
                         else:
                             ams_label = f"AMS-{chr(65 + ams_id)}"
                             ams_label = f"AMS-{chr(65 + ams_id)}"
@@ -1067,9 +1083,15 @@ async def record_ams_history():
                                 _ams_alarm_cooldown[cooldown_key] = now
                                 _ams_alarm_cooldown[cooldown_key] = now
                                 logger.info(f"Sending humidity alarm for {printer.name} {ams_label}: {humidity}% > {humidity_threshold}%")
                                 logger.info(f"Sending humidity alarm for {printer.name} {ams_label}: {humidity}% > {humidity_threshold}%")
                                 try:
                                 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:
                                 except Exception as e:
                                     logger.warning(f"Failed to send humidity alarm: {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
                                 _ams_alarm_cooldown[cooldown_key] = now
                                 logger.info(f"Sending temperature alarm for {printer.name} {ams_label}: {temperature}°C > {temp_threshold}°C")
                                 logger.info(f"Sending temperature alarm for {printer.name} {ams_label}: {temperature}°C > {temp_threshold}°C")
                                 try:
                                 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:
                                 except Exception as e:
                                     logger.warning(f"Failed to send temperature alarm: {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_filament_low = Column(Boolean, default=False)
     on_maintenance_due = Column(Boolean, default=False)  # Maintenance reminder
     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 (do not disturb)
     quiet_hours_enabled = Column(Boolean, default=False)
     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_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")
     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_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")
     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
     quiet_hours_enabled: bool = Field(default=False, description="Enable 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")
     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_filament_low: bool | None = None
     on_maintenance_due: 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_humidity_high: bool | None = None
     on_ams_temperature_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
     quiet_hours_enabled: bool | None = None
     quiet_hours_enabled: bool | None = None
     quiet_hours_start: str | None = None
     quiet_hours_start: str | None = None

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

@@ -626,9 +626,9 @@ class NotificationService:
         variables = {
         variables = {
             "printer": printer_name,
             "printer": printer_name,
             "filename": filename,
             "filename": filename,
-            "duration": "",
-            "filament_grams": "",
-            "reason": "",
+            "duration": "Unknown",
+            "filament_grams": "Unknown",
+            "reason": "Unknown",
         }
         }
 
 
         if archive_data:
         if archive_data:
@@ -639,6 +639,8 @@ class NotificationService:
             if status == "failed" and archive_data.get("failure_reason"):
             if status == "failed" and archive_data.get("failure_reason"):
                 variables["reason"] = archive_data["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]}")
         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)
         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)
         await self._send_to_providers(providers, title, message, db, event_type, printer_id, printer_name)
@@ -661,7 +663,7 @@ class NotificationService:
             "printer": printer_name,
             "printer": printer_name,
             "filename": self._clean_filename(filename),
             "filename": self._clean_filename(filename),
             "progress": str(progress),
             "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)
         title, message = await self._build_message_from_template(db, "print_progress", variables)
@@ -696,7 +698,7 @@ class NotificationService:
         variables = {
         variables = {
             "printer": printer_name,
             "printer": printer_name,
             "error_type": error_type,
             "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)
         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
         # 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)
         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):
     def clear_template_cache(self):
         """Clear the template cache. Call this when templates are updated."""
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()
         self._template_cache.clear()

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

@@ -61,6 +61,8 @@ const createMockProvider = (
   on_maintenance_due: false,
   on_maintenance_due: false,
   on_ams_humidity_high: false,
   on_ams_humidity_high: false,
   on_ams_temperature_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_enabled: false,
   quiet_hours_start: null,
   quiet_hours_start: null,
   quiet_hours_end: null,
   quiet_hours_end: null,

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

@@ -54,6 +54,8 @@ const mockNotificationProviders = [
     on_maintenance_due: false,
     on_maintenance_due: false,
     on_ams_humidity_high: false,
     on_ams_humidity_high: false,
     on_ams_temperature_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_enabled: false,
     quiet_hours_start: null,
     quiet_hours_start: null,
     quiet_hours_end: null,
     quiet_hours_end: null,

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

@@ -785,9 +785,12 @@ export interface NotificationProvider {
   on_printer_error: boolean;
   on_printer_error: boolean;
   on_filament_low: boolean;
   on_filament_low: boolean;
   on_maintenance_due: boolean;
   on_maintenance_due: boolean;
-  // AMS environmental alarms
+  // AMS environmental alarms (regular AMS)
   on_ams_humidity_high: boolean;
   on_ams_humidity_high: boolean;
   on_ams_temperature_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
   quiet_hours_enabled: boolean;
   quiet_hours_enabled: boolean;
   quiet_hours_start: string | null;
   quiet_hours_start: string | null;
@@ -822,9 +825,12 @@ export interface NotificationProviderCreate {
   on_printer_error?: boolean;
   on_printer_error?: boolean;
   on_filament_low?: boolean;
   on_filament_low?: boolean;
   on_maintenance_due?: boolean;
   on_maintenance_due?: boolean;
-  // AMS environmental alarms
+  // AMS environmental alarms (regular AMS)
   on_ams_humidity_high?: boolean;
   on_ams_humidity_high?: boolean;
   on_ams_temperature_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
   quiet_hours_enabled?: boolean;
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
   quiet_hours_start?: string | null;
@@ -852,9 +858,12 @@ export interface NotificationProviderUpdate {
   on_printer_error?: boolean;
   on_printer_error?: boolean;
   on_filament_low?: boolean;
   on_filament_low?: boolean;
   on_maintenance_due?: boolean;
   on_maintenance_due?: boolean;
-  // AMS environmental alarms
+  // AMS environmental alarms (regular AMS)
   on_ams_humidity_high?: boolean;
   on_ams_humidity_high?: boolean;
   on_ams_temperature_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
   quiet_hours_enabled?: boolean;
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
   quiet_hours_start?: string | null;

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

@@ -33,6 +33,12 @@ interface DashboardProps {
   columns?: number;
   columns?: number;
   hideControls?: boolean;
   hideControls?: boolean;
   onResetLayout?: () => void;
   onResetLayout?: () => void;
+  renderControls?: (controls: {
+    hiddenCount: number;
+    showHiddenPanel: boolean;
+    setShowHiddenPanel: (show: boolean) => void;
+    resetLayout: () => void;
+  }) => ReactNode;
 }
 }
 
 
 interface LayoutState {
 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
   // Build default sizes from widget definitions
   const getDefaultSizes = () => {
   const getDefaultSizes = () => {
     const sizes: Record<string, 1 | 2 | 4> = {};
     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);
   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
   // Save layout to localStorage whenever it changes
   useEffect(() => {
   useEffect(() => {
     localStorage.setItem(storageKey, JSON.stringify(layout));
     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 visibleWidgets = orderedWidgets.filter((w) => !layout.hidden.includes(w.id));
   const hiddenWidgets = 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 (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
+      {/* External controls slot */}
+      {externalControls}
+
       {/* Dashboard Controls */}
       {/* Dashboard Controls */}
-      {!hideControls && (
+      {!hideControls && !renderControls && (
         <div className="flex items-center justify-end gap-2">
         <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 && (
           {hiddenWidgets.length > 0 && (
             <Button
             <Button
               variant="secondary"
               variant="secondary"
@@ -261,10 +289,6 @@ export function Dashboard({ widgets, storageKey, columns = 4, hideControls = fal
               {hiddenWidgets.length} Hidden
               {hiddenWidgets.length} Hidden
             </Button>
             </Button>
           )}
           )}
-          <Button variant="secondary" size="sm" onClick={resetLayout}>
-            <RotateCcw className="w-4 h-4" />
-            Reset Layout
-          </Button>
         </div>
         </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>
               <span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">Maintenance</span>
             )}
             )}
             {provider.on_ams_humidity_high && (
             {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 && (
             {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 && (
             {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">
               <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>
               </div>
               </div>
 
 
-              {/* AMS Environmental Alarms */}
+              {/* AMS Environmental Alarms (regular AMS) */}
               <div className="space-y-2">
               <div className="space-y-2">
                 <p className="text-xs text-bambu-gray uppercase tracking-wide">AMS Alarms</p>
                 <p className="text-xs text-bambu-gray uppercase tracking-wide">AMS Alarms</p>
 
 
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <div>
                   <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>
                   </div>
                   <Toggle
                   <Toggle
                     checked={provider.on_ams_humidity_high ?? false}
                     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 className="flex items-center justify-between">
                   <div>
                   <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>
                   </div>
                   <Toggle
                   <Toggle
                     checked={provider.on_ams_temperature_high ?? false}
                     checked={provider.on_ams_temperature_high ?? false}
@@ -350,6 +356,33 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                 </div>
                 </div>
               </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 */}
               {/* Quiet Hours */}
               <div className="space-y-2">
               <div className="space-y-2">
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">

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

@@ -34,14 +34,42 @@ export function CameraPage() {
     };
     };
   }, [printer]);
   }, [printer]);
 
 
-  // Cleanup on unmount
+  // Cleanup on unmount - stop the camera stream
   useEffect(() => {
   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 () => {
     return () => {
+      window.removeEventListener('beforeunload', handleBeforeUnload);
+      document.removeEventListener('visibilitychange', handleVisibilityChange);
+
+      // Clear the image source
       if (imgRef.current) {
       if (imgRef.current) {
         imgRef.current.src = '';
         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
   // Auto-hide loading after timeout
   useEffect(() => {
   useEffect(() => {
@@ -73,6 +101,12 @@ export function CameraPage() {
     setStreamError(false);
     setStreamError(false);
   };
   };
 
 
+  const stopStream = () => {
+    if (id > 0) {
+      fetch(`/api/v1/printers/${id}/camera/stop`).catch(() => {});
+    }
+  };
+
   const switchToMode = (newMode: 'stream' | 'snapshot') => {
   const switchToMode = (newMode: 'stream' | 'snapshot') => {
     if (streamMode === newMode || transitioning) return;
     if (streamMode === newMode || transitioning) return;
     setTransitioning(true);
     setTransitioning(true);
@@ -83,6 +117,11 @@ export function CameraPage() {
       imgRef.current.src = '';
       imgRef.current.src = '';
     }
     }
 
 
+    // Stop any active streams when switching modes
+    if (streamMode === 'stream') {
+      stopStream();
+    }
+
     setTimeout(() => {
     setTimeout(() => {
       setStreamMode(newMode);
       setStreamMode(newMode);
       setImageKey(Date.now());
       setImageKey(Date.now());
@@ -100,6 +139,11 @@ export function CameraPage() {
       imgRef.current.src = '';
       imgRef.current.src = '';
     }
     }
 
 
+    // Stop any active streams before refresh
+    if (streamMode === 'stream') {
+      stopStream();
+    }
+
     setTimeout(() => {
     setTimeout(() => {
       setImageKey(Date.now());
       setImageKey(Date.now());
       setTransitioning(false);
       setTransitioning(false);

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

@@ -51,6 +51,7 @@ export function SettingsPage() {
   const [showBackupModal, setShowBackupModal] = useState(false);
   const [showBackupModal, setShowBackupModal] = useState(false);
   const [showRestoreModal, setShowRestoreModal] = useState(false);
   const [showRestoreModal, setShowRestoreModal] = useState(false);
   const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
   const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
+  const [showReleaseNotes, setShowReleaseNotes] = useState(false);
 
 
   const handleDefaultViewChange = (path: string) => {
   const handleDefaultViewChange = (path: string) => {
     setDefaultViewState(path);
     setDefaultViewState(path);
@@ -126,7 +127,6 @@ export function SettingsPage() {
   const { data: apiKeys, isLoading: apiKeysLoading } = useQuery({
   const { data: apiKeys, isLoading: apiKeysLoading } = useQuery({
     queryKey: ['api-keys'],
     queryKey: ['api-keys'],
     queryFn: api.getAPIKeys,
     queryFn: api.getAPIKeys,
-    enabled: activeTab === 'apikeys',
   });
   });
 
 
   const createAPIKeyMutation = useMutation({
   const createAPIKeyMutation = useMutation({
@@ -822,12 +822,12 @@ export function SettingsPage() {
           </Card>
           </Card>
 
 
           <SpoolmanSettings />
           <SpoolmanSettings />
-
-          <ExternalLinksSettings />
         </div>
         </div>
 
 
         {/* Third Column - Updates */}
         {/* Third Column - Updates */}
         <div className="space-y-6 flex-1 lg:max-w-sm">
         <div className="space-y-6 flex-1 lg:max-w-sm">
+          <ExternalLinksSettings />
+
           <Card>
           <Card>
             <CardHeader>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">Updates</h2>
               <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 && (
                         {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
                           <p className="text-sm text-bambu-gray mt-1">{updateCheck.release_name}</p>
                           <p className="text-sm text-bambu-gray mt-1">{updateCheck.release_name}</p>
                         )}
                         )}
+                      </div>
+                      <div className="flex items-center gap-2">
                         {updateCheck.release_notes && (
                         {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>
                       </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>
                     </div>
 
 
                     {updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (
                     {updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (
@@ -1938,6 +1943,59 @@ export function SettingsPage() {
           </Card>
           </Card>
         </div>
         </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>
     </div>
   );
   );
 }
 }

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

@@ -1,5 +1,5 @@
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
 import {
 import {
   Package,
   Package,
   Clock,
   Clock,
@@ -14,6 +14,7 @@ import {
   FileSpreadsheet,
   FileSpreadsheet,
   FileText,
   FileText,
   Loader2,
   Loader2,
+  Eye,
   RotateCcw,
   RotateCcw,
 } from 'lucide-react';
 } from 'lucide-react';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -393,6 +394,31 @@ export function StatsPage() {
   const [isExporting, setIsExporting] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [dashboardKey, setDashboardKey] = useState(0);
   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({
   const { data: stats, isLoading } = useQuery({
     queryKey: ['archiveStats'],
     queryKey: ['archiveStats'],
@@ -406,7 +432,7 @@ export function StatsPage() {
 
 
   const { data: archives } = useQuery({
   const { data: archives } = useQuery({
     queryKey: ['archives'],
     queryKey: ['archives'],
-    queryFn: () => api.getArchives(undefined, 1000, 0),
+    queryFn: () => api.getArchives(undefined, undefined, 1000, 0),
   });
   });
 
 
   const { data: settings } = useQuery({
   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 (
   return (
     <div className="p-4 md:p-8">
     <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>
           <p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
         </div>
         </div>
         <div className="flex items-center gap-2">
         <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
           <Button
             variant="secondary"
             variant="secondary"
-            onClick={handleResetLayout}
+            onClick={() => {
+              localStorage.removeItem('bambusy-dashboard-layout');
+              setDashboardKey(prev => prev + 1);
+              showToast('Layout reset');
+            }}
           >
           >
             <RotateCcw className="w-4 h-4" />
             <RotateCcw className="w-4 h-4" />
             Reset Layout
             Reset Layout
@@ -555,7 +594,12 @@ export function StatsPage() {
         </div>
         </div>
       </div>
       </div>
 
 
-      <Dashboard key={dashboardKey} widgets={widgets} storageKey="bambusy-dashboard-layout" hideControls />
+      <Dashboard
+        key={dashboardKey}
+        widgets={widgets}
+        storageKey="bambusy-dashboard-layout"
+        hideControls
+      />
     </div>
     </div>
   );
   );
 }
 }

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


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


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


+ 2 - 2
static/index.html

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

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