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)
This commit is contained in:
MartinNYHC
2025-12-12 09:38:56 +01:00
committed by GitHub
23 changed files with 676 additions and 185 deletions
+67 -8
View File
@@ -2,17 +2,76 @@
All notable changes to Bambuddy will be documented in this file.
## [Unreleased]
## [0.1.5b6] - 2025-12-12
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)
## [0.1.5b5] - 2025-12-11
### 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
- Anonymous telemetry system with opt-out support
- System info page with database and resource statistics
### 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)
## [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
+12 -2
View File
@@ -52,7 +52,8 @@
### 📊 Monitoring & Stats
- Real-time printer status via WebSocket
- HMS error monitoring
- Live camera streaming (MJPEG) & snapshots
- HMS error monitoring with history
- Print success rates & trends
- Filament usage tracking
- Cost analytics & failure analysis
@@ -62,6 +63,7 @@
- Print queue with drag-and-drop
- Scheduled prints (date/time)
- Smart plug integration (Tasmota)
- Energy consumption tracking
- Auto power-on before print
- Auto power-off after cooldown
@@ -94,11 +96,19 @@
- Print time accuracy stats
- 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>
</tr>
</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
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,
+1 -1
View File
@@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings
import logging
# Application version - single source of truth
APP_VERSION = "0.1.5b5"
APP_VERSION = "0.1.5b6"
GITHUB_REPO = "maziggy/bambuddy"
# Base directory for path calculations
+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>