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
This commit is contained in:
maziggy
2025-12-12 09:18:59 +01:00
parent f4cc71b41a
commit 1a4f146dd0
19 changed files with 596 additions and 174 deletions
+107 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>