mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user