- Added smart plug monitoring and scheduling

- Added daily digest to notification module
This commit is contained in:
maziggy
2025-12-08 16:30:53 +00:00
parent 29aadfb774
commit c74f24353a
25 changed files with 10873 additions and 8739 deletions
+211 -17
View File
@@ -2,14 +2,17 @@
import json
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import get_db
from backend.app.models.notification import NotificationProvider
from backend.app.models.notification import NotificationLog, NotificationProvider
from backend.app.schemas.notification import (
NotificationLogResponse,
NotificationLogStats,
NotificationProviderCreate,
NotificationProviderResponse,
NotificationProviderUpdate,
@@ -46,6 +49,9 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
"quiet_hours_enabled": provider.quiet_hours_enabled,
"quiet_hours_start": provider.quiet_hours_start,
"quiet_hours_end": provider.quiet_hours_end,
# Daily digest
"daily_digest_enabled": provider.daily_digest_enabled,
"daily_digest_time": provider.daily_digest_time,
# Printer filter
"printer_id": provider.printer_id,
# Status tracking
@@ -58,6 +64,10 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
}
# ============================================================================
# Provider List/Create Routes (no path parameters)
# ============================================================================
@router.get("/", response_model=list[NotificationProviderResponse])
async def list_notification_providers(db: AsyncSession = Depends(get_db)):
"""List all notification providers."""
@@ -106,6 +116,204 @@ async def create_notification_provider(
return _provider_to_dict(provider)
# ============================================================================
# Static Path Routes (must come BEFORE parameterized routes)
# ============================================================================
@router.post("/test-config", response_model=NotificationTestResponse)
async def test_notification_config(
test_request: NotificationTestRequest,
):
"""Test notification configuration before saving."""
success, message = await notification_service.send_test_notification(
test_request.provider_type.value, test_request.config
)
return NotificationTestResponse(success=success, message=message)
@router.post("/test-all")
async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
"""Send a test notification to all enabled providers."""
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.enabled == True)
)
providers = result.scalars().all()
if not providers:
return {"tested": 0, "success": 0, "failed": 0, "results": []}
results = []
success_count = 0
failed_count = 0
for provider in providers:
config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
success, message = await notification_service.send_test_notification(
provider.provider_type, config
)
# Update provider status
if success:
provider.last_success = datetime.utcnow()
success_count += 1
else:
provider.last_error = message
provider.last_error_at = datetime.utcnow()
failed_count += 1
results.append({
"provider_id": provider.id,
"provider_name": provider.name,
"provider_type": provider.provider_type,
"success": success,
"message": message,
})
await db.commit()
return {
"tested": len(providers),
"success": success_count,
"failed": failed_count,
"results": results,
}
# ============================================================================
# Notification Log Routes (must come BEFORE /{provider_id} routes)
# ============================================================================
@router.get("/logs", response_model=list[NotificationLogResponse])
async def get_notification_logs(
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
provider_id: int | None = Query(default=None),
event_type: str | None = Query(default=None),
success: bool | None = Query(default=None),
days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
db: AsyncSession = Depends(get_db),
):
"""Get notification logs with optional filters."""
query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
# Apply filters
if provider_id is not None:
query = query.where(NotificationLog.provider_id == provider_id)
if event_type is not None:
query = query.where(NotificationLog.event_type == event_type)
if success is not None:
query = query.where(NotificationLog.success == success)
if days is not None:
cutoff = datetime.utcnow() - timedelta(days=days)
query = query.where(NotificationLog.created_at >= cutoff)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
logs = result.scalars().all()
# Get provider info for each log
response = []
providers_cache: dict[int, NotificationProvider | None] = {}
for log in logs:
if log.provider_id not in providers_cache:
provider_result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == log.provider_id)
)
providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
provider = providers_cache[log.provider_id]
response.append(NotificationLogResponse(
id=log.id,
provider_id=log.provider_id,
provider_name=provider.name if provider else None,
provider_type=provider.provider_type if provider else None,
event_type=log.event_type,
title=log.title,
message=log.message,
success=log.success,
error_message=log.error_message,
printer_id=log.printer_id,
printer_name=log.printer_name,
created_at=log.created_at,
))
return response
@router.get("/logs/stats", response_model=NotificationLogStats)
async def get_notification_log_stats(
days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
db: AsyncSession = Depends(get_db),
):
"""Get notification log statistics."""
cutoff = datetime.utcnow() - timedelta(days=days)
# Total counts
total_result = await db.execute(
select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff)
)
total = total_result.scalar() or 0
success_result = await db.execute(
select(func.count(NotificationLog.id)).where(
NotificationLog.created_at >= cutoff,
NotificationLog.success == True
)
)
success_count = success_result.scalar() or 0
# By event type
event_result = await db.execute(
select(NotificationLog.event_type, func.count(NotificationLog.id))
.where(NotificationLog.created_at >= cutoff)
.group_by(NotificationLog.event_type)
)
by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
# By provider (need to join to get name)
provider_result = await db.execute(
select(NotificationProvider.name, func.count(NotificationLog.id))
.join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)
.where(NotificationLog.created_at >= cutoff)
.group_by(NotificationProvider.name)
)
by_provider = {row[0]: row[1] for row in provider_result.fetchall()}
return NotificationLogStats(
total=total,
success_count=success_count,
failure_count=total - success_count,
by_event_type=by_event_type,
by_provider=by_provider,
)
@router.delete("/logs")
async def clear_notification_logs(
older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
db: AsyncSession = Depends(get_db),
):
"""Clear old notification logs."""
cutoff = datetime.utcnow() - timedelta(days=older_than_days)
result = await db.execute(
delete(NotificationLog).where(NotificationLog.created_at < cutoff)
)
await db.commit()
deleted_count = result.rowcount
logger.info(f"Deleted {deleted_count} notification logs older than {older_than_days} days")
return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs older than {older_than_days} days"}
# ============================================================================
# Provider Instance Routes (parameterized - must come LAST)
# ============================================================================
@router.get("/{provider_id}", response_model=NotificationProviderResponse)
async def get_notification_provider(
provider_id: int,
@@ -201,25 +409,11 @@ async def test_notification_provider(
# Update provider status
if success:
from datetime import datetime
provider.last_success = datetime.utcnow()
else:
from datetime import datetime
provider.last_error = message
provider.last_error_at = datetime.utcnow()
await db.commit()
return NotificationTestResponse(success=success, message=message)
@router.post("/test-config", response_model=NotificationTestResponse)
async def test_notification_config(
test_request: NotificationTestRequest,
):
"""Test notification configuration before saving."""
success, message = await notification_service.send_test_notification(
test_request.provider_type.value, test_request.config
)
return NotificationTestResponse(success=success, message=message)
+154 -2
View File
@@ -1,9 +1,15 @@
from fastapi import APIRouter, Depends
import json
from datetime import datetime
from fastapi import APIRouter, Depends, UploadFile, File
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.app.core.database import get_db
from backend.app.models.settings import Settings
from backend.app.models.notification import NotificationProvider
from backend.app.models.smart_plug import SmartPlug
from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
@@ -46,8 +52,13 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
# Parse the value based on the expected type
if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates"]:
settings_dict[setting.key] = setting.value.lower() == "true"
elif setting.key in ["default_filament_cost", "energy_cost_per_kwh"]:
elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
settings_dict[setting.key] = float(setting.value)
elif setting.key in ["ams_humidity_good", "ams_humidity_fair"]:
settings_dict[setting.key] = int(setting.value)
elif setting.key == "default_printer_id":
# Handle nullable integer
settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
else:
settings_dict[setting.key] = setting.value
@@ -66,6 +77,8 @@ async def update_settings(
# Convert value to string for storage
if isinstance(value, bool):
str_value = "true" if value else "false"
elif value is None:
str_value = "None"
else:
str_value = str(value)
await set_setting(db, key, str_value)
@@ -133,3 +146,142 @@ async def update_spoolman_settings(
# Return updated settings
return await get_spoolman_settings(db)
@router.get("/backup")
async def export_backup(db: AsyncSession = Depends(get_db)):
"""Export all settings, notification providers, and smart plugs as JSON backup."""
# Get all settings
result = await db.execute(select(Settings))
db_settings = result.scalars().all()
settings_data = {s.key: s.value for s in db_settings}
# Get notification providers
result = await db.execute(select(NotificationProvider))
providers = result.scalars().all()
providers_data = []
for p in providers:
providers_data.append({
"name": p.name,
"provider_type": p.provider_type,
"enabled": p.enabled,
"config": json.loads(p.config) if isinstance(p.config, str) else p.config,
"on_print_start": p.on_print_start,
"on_print_complete": p.on_print_complete,
"on_print_failed": p.on_print_failed,
"on_print_stopped": p.on_print_stopped,
"on_print_progress": p.on_print_progress,
"on_printer_offline": p.on_printer_offline,
"on_printer_error": p.on_printer_error,
"on_filament_low": p.on_filament_low,
"on_maintenance_due": p.on_maintenance_due,
"quiet_hours_enabled": p.quiet_hours_enabled,
"quiet_hours_start": p.quiet_hours_start,
"quiet_hours_end": p.quiet_hours_end,
})
# Get smart plugs
result = await db.execute(select(SmartPlug))
plugs = result.scalars().all()
plugs_data = []
for plug in plugs:
plugs_data.append({
"name": plug.name,
"ip_address": plug.ip_address,
"enabled": plug.enabled,
"auto_off_enabled": plug.auto_off_enabled,
"auto_off_delay_minutes": plug.auto_off_delay_minutes,
})
backup = {
"version": "1.0",
"exported_at": datetime.utcnow().isoformat(),
"settings": settings_data,
"notification_providers": providers_data,
"smart_plugs": plugs_data,
}
return JSONResponse(
content=backup,
headers={
"Content-Disposition": f"attachment; filename=bambutrack-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
}
)
@router.post("/restore")
async def import_backup(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
):
"""Restore settings, notification providers, and smart plugs from JSON backup."""
try:
content = await file.read()
backup = json.loads(content.decode("utf-8"))
except Exception as e:
return {"success": False, "message": f"Invalid backup file: {str(e)}"}
restored = {"settings": 0, "notification_providers": 0, "smart_plugs": 0}
# Restore settings
if "settings" in backup:
for key, value in backup["settings"].items():
await set_setting(db, key, value)
restored["settings"] += 1
# Restore notification providers (skip duplicates by name)
if "notification_providers" in backup:
for provider_data in backup["notification_providers"]:
# Check if provider with same name exists
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
)
existing = result.scalar_one_or_none()
if not existing:
provider = NotificationProvider(
name=provider_data["name"],
provider_type=provider_data["provider_type"],
enabled=provider_data.get("enabled", True),
config=json.dumps(provider_data.get("config", {})),
on_print_start=provider_data.get("on_print_start", False),
on_print_complete=provider_data.get("on_print_complete", True),
on_print_failed=provider_data.get("on_print_failed", True),
on_print_stopped=provider_data.get("on_print_stopped", True),
on_print_progress=provider_data.get("on_print_progress", False),
on_printer_offline=provider_data.get("on_printer_offline", False),
on_printer_error=provider_data.get("on_printer_error", False),
on_filament_low=provider_data.get("on_filament_low", False),
on_maintenance_due=provider_data.get("on_maintenance_due", False),
quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
quiet_hours_start=provider_data.get("quiet_hours_start"),
quiet_hours_end=provider_data.get("quiet_hours_end"),
)
db.add(provider)
restored["notification_providers"] += 1
# Restore smart plugs (skip duplicates by IP)
if "smart_plugs" in backup:
for plug_data in backup["smart_plugs"]:
# Check if plug with same IP exists
result = await db.execute(
select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])
)
existing = result.scalar_one_or_none()
if not existing:
plug = SmartPlug(
name=plug_data["name"],
ip_address=plug_data["ip_address"],
enabled=plug_data.get("enabled", True),
auto_off_enabled=plug_data.get("auto_off_enabled", False),
auto_off_delay_minutes=plug_data.get("auto_off_delay_minutes", 5),
)
db.add(plug)
restored["smart_plugs"] += 1
await db.commit()
return {
"success": True,
"message": f"Restored {restored['settings']} settings, {restored['notification_providers']} notification providers, {restored['smart_plugs']} smart plugs",
"restored": restored,
}
+60 -1
View File
@@ -1,7 +1,7 @@
"""API routes for smart plug management."""
import logging
from datetime import datetime
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,6 +21,7 @@ from backend.app.schemas.smart_plug import (
)
from backend.app.services.tasmota import tasmota_service
from backend.app.services.printer_manager import printer_manager
from backend.app.services.notification_service import notification_service
logger = logging.getLogger(__name__)
@@ -211,6 +212,9 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
if energy:
energy_data = SmartPlugEnergy(**energy)
# Check power alerts
await check_power_alerts(plug, energy.get("power"), db)
return SmartPlugStatus(
state=status["state"],
reachable=status["reachable"],
@@ -219,6 +223,61 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
)
async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: AsyncSession):
"""Check if power crosses alert thresholds and send notifications."""
if not plug.power_alert_enabled or current_power is None:
return
# Cooldown: don't alert more than once per 5 minutes
cooldown_minutes = 5
if plug.power_alert_last_triggered:
time_since_last = datetime.utcnow() - plug.power_alert_last_triggered
if time_since_last < timedelta(minutes=cooldown_minutes):
return
alert_triggered = False
alert_type = None
threshold = None
# Check high threshold
if plug.power_alert_high is not None and current_power > plug.power_alert_high:
alert_triggered = True
alert_type = "high"
threshold = plug.power_alert_high
# Check low threshold
if plug.power_alert_low is not None and current_power < plug.power_alert_low:
alert_triggered = True
alert_type = "low"
threshold = plug.power_alert_low
if alert_triggered:
plug.power_alert_last_triggered = datetime.utcnow()
await db.commit()
# Send notification
title = f"Power Alert: {plug.name}"
if alert_type == "high":
message = f"Power consumption is {current_power:.1f}W, above threshold of {threshold:.1f}W"
else:
message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
logger.info(f"Power alert triggered for {plug.name}: {message}")
# Use printer_error event type for power alerts (closest match)
await notification_service.send_notification(
event_type="printer_error",
title=title,
message=message,
printer_id=plug.printer_id,
printer_name=plug.name,
context={
"error_type": f"Power {alert_type.title()}",
"error_detail": message,
},
)
@router.post("/test-connection")
async def test_connection(data: SmartPlugTestConnection):
"""Test connection to a Tasmota device."""
+60
View File
@@ -131,6 +131,66 @@ async def run_migrations(conn):
# Column already exists
pass
# Migration: Add power alert columns to smart_plugs
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0"
))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL"
))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL"
))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME"
))
except Exception:
pass
# Migration: Add schedule columns to smart_plugs
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0"
))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)"
))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)"
))
except Exception:
pass
# Migration: Add daily digest columns to notification_providers
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0"
))
except Exception:
pass
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)"
))
except Exception:
pass
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
+8
View File
@@ -993,10 +993,18 @@ async def lifespan(app: FastAPI):
# Start the print scheduler
asyncio.create_task(print_scheduler.run())
# Start the smart plug scheduler for time-based on/off
smart_plug_manager.start_scheduler()
# Start the notification digest scheduler
notification_service.start_digest_scheduler()
yield
# Shutdown
print_scheduler.stop()
smart_plug_manager.stop_scheduler()
notification_service.stop_digest_scheduler()
printer_manager.disconnect_all()
await close_spoolman_client()
+2
View File
@@ -6,6 +6,7 @@ from backend.app.models.smart_plug import SmartPlug
from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
from backend.app.models.kprofile_note import KProfileNote
from backend.app.models.notification_template import NotificationTemplate
from backend.app.models.notification import NotificationLog
__all__ = [
"Printer",
@@ -18,4 +19,5 @@ __all__ = [
"MaintenanceHistory",
"KProfileNote",
"NotificationTemplate",
"NotificationLog",
]
+45 -1
View File
@@ -1,4 +1,4 @@
"""Notification provider model for push notifications."""
"""Notification provider and log models for push notifications."""
from datetime import datetime
@@ -8,6 +8,44 @@ from sqlalchemy.orm import relationship
from backend.app.core.database import Base
class NotificationDigestQueue(Base):
"""Model for queuing notifications to be sent in daily digest."""
__tablename__ = "notification_digest_queue"
id = Column(Integer, primary_key=True, index=True)
provider_id = Column(Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False)
event_type = Column(String(50), nullable=False) # print_start, print_complete, etc.
title = Column(String(255), nullable=False)
message = Column(Text, nullable=False)
printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
printer_name = Column(String(100), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
# Relationships
provider = relationship("NotificationProvider", back_populates="digest_queue")
class NotificationLog(Base):
"""Model for logging sent notifications."""
__tablename__ = "notification_logs"
id = Column(Integer, primary_key=True, index=True)
provider_id = Column(Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False)
event_type = Column(String(50), nullable=False) # print_start, print_complete, etc.
title = Column(String(255), nullable=False)
message = Column(Text, nullable=False)
success = Column(Boolean, default=True)
error_message = Column(Text, nullable=True)
printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
printer_name = Column(String(100), nullable=True) # Store name in case printer is deleted
created_at = Column(DateTime, default=datetime.utcnow, index=True)
# Relationships
provider = relationship("NotificationProvider", back_populates="logs")
class NotificationProvider(Base):
"""Model for notification providers (WhatsApp, ntfy, Pushover, etc.)."""
@@ -39,6 +77,10 @@ class NotificationProvider(Base):
quiet_hours_start = Column(String(5), nullable=True) # HH:MM format, e.g., "22:00"
quiet_hours_end = Column(String(5), nullable=True) # HH:MM format, e.g., "07:00"
# Daily digest (batch notifications into a single daily summary)
daily_digest_enabled = Column(Boolean, default=False)
daily_digest_time = Column(String(5), nullable=True) # HH:MM format, e.g., "08:00"
# Optional: Link to specific printer (NULL = all printers)
printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
@@ -53,3 +95,5 @@ class NotificationProvider(Base):
# Relationships
printer = relationship("Printer", back_populates="notification_providers")
logs = relationship("NotificationLog", back_populates="provider", cascade="all, delete-orphan")
digest_queue = relationship("NotificationDigestQueue", back_populates="provider", cascade="all, delete-orphan")
+12 -1
View File
@@ -1,5 +1,5 @@
from datetime import datetime
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, func
from sqlalchemy import String, Boolean, Integer, Float, DateTime, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
@@ -33,6 +33,17 @@ class SmartPlug(Base):
username: Mapped[str | None] = mapped_column(String(50), nullable=True)
password: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Power alerts
power_alert_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
power_alert_high: Mapped[float | None] = mapped_column(Float, nullable=True) # Alert when power > this (watts)
power_alert_low: Mapped[float | None] = mapped_column(Float, nullable=True) # Alert when power < this (watts)
power_alert_last_triggered: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Cooldown tracking
# Schedule (time-based on/off)
schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
schedule_on_time: Mapped[str | None] = mapped_column(String(5), nullable=True) # "HH:MM" format
schedule_off_time: Mapped[str | None] = mapped_column(String(5), nullable=True) # "HH:MM" format
# Status tracking
last_state: Mapped[str | None] = mapped_column(String(10), nullable=True) # "ON"/"OFF"
last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+40 -1
View File
@@ -43,10 +43,14 @@ class NotificationProviderBase(BaseModel):
quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
quiet_hours_end: str | None = Field(default=None, description="End time in HH:MM format")
# Daily digest
daily_digest_enabled: bool = Field(default=False, description="Batch notifications into daily digest")
daily_digest_time: str | None = Field(default=None, description="Time to send digest in HH:MM format")
# Printer filter
printer_id: int | None = Field(default=None, description="Specific printer ID or null for all")
@field_validator("quiet_hours_start", "quiet_hours_end")
@field_validator("quiet_hours_start", "quiet_hours_end", "daily_digest_time")
@classmethod
def validate_time_format(cls, v: str | None) -> str | None:
if v is None:
@@ -95,6 +99,10 @@ class NotificationProviderUpdate(BaseModel):
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
# Daily digest
daily_digest_enabled: bool | None = None
daily_digest_time: str | None = None
# Printer filter
printer_id: int | None = None
@@ -168,3 +176,34 @@ class EmailConfig(BaseModel):
from_email: str = Field(..., description="From email address")
to_email: str = Field(..., description="Recipient email address")
use_tls: bool = Field(default=True, description="Use TLS encryption")
# Notification Log schemas
class NotificationLogResponse(BaseModel):
"""Schema for notification log API responses."""
id: int
provider_id: int
provider_name: str | None = None
provider_type: str | None = None
event_type: str
title: str
message: str
success: bool
error_message: str | None = None
printer_id: int | None = None
printer_name: str | None = None
created_at: datetime
class Config:
from_attributes = True
class NotificationLogStats(BaseModel):
"""Statistics for notification logs."""
total: int
success_count: int
failure_count: int
by_event_type: dict[str, int]
by_provider: dict[str, int]
+10
View File
@@ -29,6 +29,13 @@ class AppSettings(BaseModel):
ams_temp_good: float = Field(default=28.0, description="Temperature threshold for good (blue): <= this value")
ams_temp_fair: float = Field(default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red")
# Date/time display format
date_format: str = Field(default="system", description="Date format: system, us, eu, iso")
time_format: str = Field(default="system", description="Time format: system, 12h, 24h")
# Default printer for operations
default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
class AppSettingsUpdate(BaseModel):
"""Schema for updating settings (all fields optional)."""
@@ -49,3 +56,6 @@ class AppSettingsUpdate(BaseModel):
ams_humidity_fair: int | None = None
ams_temp_good: float | None = None
ams_temp_fair: float | None = None
date_format: str | None = None
time_format: str | None = None
default_printer_id: int | None = None
+17
View File
@@ -15,6 +15,14 @@ class SmartPlugBase(BaseModel):
off_temp_threshold: int = Field(default=70, ge=30, le=150)
username: str | None = None
password: str | None = None
# Power alerts
power_alert_enabled: bool = False
power_alert_high: float | None = Field(default=None, ge=0, le=5000) # Alert when power > this (watts)
power_alert_low: float | None = Field(default=None, ge=0, le=5000) # Alert when power < this (watts)
# Schedule
schedule_enabled: bool = False
schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format
class SmartPlugCreate(SmartPlugBase):
@@ -33,6 +41,14 @@ class SmartPlugUpdate(BaseModel):
off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
username: str | None = None
password: str | None = None
# Power alerts
power_alert_enabled: bool | None = None
power_alert_high: float | None = Field(default=None, ge=0, le=5000)
power_alert_low: float | None = Field(default=None, ge=0, le=5000)
# Schedule
schedule_enabled: bool | None = None
schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
class SmartPlugResponse(SmartPlugBase):
@@ -40,6 +56,7 @@ class SmartPlugResponse(SmartPlugBase):
last_state: str | None = None
last_checked: datetime | None = None
auto_off_executed: bool = False # True when auto-off was triggered after print
power_alert_last_triggered: datetime | None = None
created_at: datetime
updated_at: datetime
+303 -9
View File
@@ -1,5 +1,6 @@
"""Notification service for sending push notifications via various providers."""
import asyncio
import json
import logging
import re
@@ -14,7 +15,7 @@ import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.notification import NotificationProvider
from backend.app.models.notification import NotificationLog, NotificationProvider, NotificationDigestQueue
from backend.app.models.notification_template import NotificationTemplate
logger = logging.getLogger(__name__)
@@ -26,6 +27,8 @@ class NotificationService:
def __init__(self):
self._http_client: httpx.AsyncClient | None = None
self._template_cache: dict[str, NotificationTemplate] = {}
self._digest_scheduler_task: asyncio.Task | None = None
self._last_digest_check: str = "" # "HH:MM" to avoid duplicate checks
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
@@ -150,6 +153,10 @@ class NotificationService:
return await self._send_telegram(config, f"*{title}*\n{message}")
elif provider_type == "email":
return await self._send_email(config, title, message)
elif provider_type == "discord":
return await self._send_discord(config, title, message)
elif provider_type == "webhook":
return await self._send_webhook(config, title, message)
else:
return False, f"Unknown provider type: {provider_type}"
except Exception as e:
@@ -308,6 +315,70 @@ class NotificationService:
except Exception as e:
return False, f"Email error: {str(e)}"
async def _send_discord(self, config: dict, title: str, message: str) -> tuple[bool, str]:
"""Send notification via Discord webhook."""
webhook_url = config.get("webhook_url", "").strip()
if not webhook_url:
return False, "Webhook URL is required"
if not webhook_url.startswith("https://discord.com/api/webhooks/"):
return False, "Invalid Discord webhook URL"
# Discord embed format for nicer messages
data = {
"embeds": [{
"title": title,
"description": message,
"color": 0x00AE42, # Bambu green
}]
}
client = await self._get_client()
response = await client.post(webhook_url, json=data)
if response.status_code in (200, 204):
return True, "Message sent successfully"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_webhook(self, config: dict, title: str, message: str) -> tuple[bool, str]:
"""Send notification via generic webhook (POST JSON)."""
webhook_url = config.get("webhook_url", "").strip()
auth_header = config.get("auth_header", "").strip()
custom_field_title = config.get("field_title", "title").strip() or "title"
custom_field_message = config.get("field_message", "message").strip() or "message"
if not webhook_url:
return False, "Webhook URL is required"
# Build payload with custom field names
data = {
custom_field_title: title,
custom_field_message: message,
"timestamp": datetime.now().isoformat(),
"source": "BambuTrack",
}
headers = {"Content-Type": "application/json"}
if auth_header:
# Support "Bearer token" or just "token" format
if " " in auth_header:
headers["Authorization"] = auth_header
else:
headers["Authorization"] = f"Bearer {auth_header}"
client = await self._get_client()
try:
response = await client.post(webhook_url, json=data, headers=headers)
if response.status_code in (200, 201, 202, 204):
return True, "Webhook delivered successfully"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
except Exception as e:
return False, f"Webhook error: {str(e)}"
async def _send_to_provider(
self, provider: NotificationProvider, title: str, message: str
) -> tuple[bool, str]:
@@ -330,6 +401,10 @@ class NotificationService:
return await self._send_telegram(config, f"*{title}*\n{message}")
elif provider.provider_type == "email":
return await self._send_email(config, title, message)
elif provider.provider_type == "discord":
return await self._send_discord(config, title, message)
elif provider.provider_type == "webhook":
return await self._send_webhook(config, title, message)
else:
return False, f"Unknown provider type: {provider.provider_type}"
except Exception as e:
@@ -373,18 +448,75 @@ class NotificationService:
result = await db.execute(query)
return list(result.scalars().all())
async def _log_notification(
self,
db: AsyncSession,
provider_id: int,
event_type: str,
title: str,
message: str,
success: bool,
error_message: str | None = None,
printer_id: int | None = None,
printer_name: str | None = None,
):
"""Create a log entry for a sent notification."""
try:
log = NotificationLog(
provider_id=provider_id,
event_type=event_type,
title=title,
message=message,
success=success,
error_message=error_message,
printer_id=printer_id,
printer_name=printer_name,
)
db.add(log)
await db.commit()
except Exception as e:
logger.warning(f"Failed to log notification: {e}")
# Don't fail the notification just because logging failed
async def _send_to_providers(
self,
providers: list[NotificationProvider],
title: str,
message: str,
db: AsyncSession,
event_type: str = "unknown",
printer_id: int | None = None,
printer_name: str | None = None,
):
"""Send notification to multiple providers."""
"""Send notification to multiple providers and log the results."""
for provider in providers:
try:
# Check if provider wants digest mode
if provider.daily_digest_enabled and provider.daily_digest_time:
await self._queue_for_digest(
provider=provider,
event_type=event_type,
title=title,
message=message,
db=db,
printer_id=printer_id,
printer_name=printer_name,
)
continue
success, error = await self._send_to_provider(provider, title, message)
await self._update_provider_status(db, provider.id, success, error if not success else None)
await self._log_notification(
db=db,
provider_id=provider.id,
event_type=event_type,
title=title,
message=message,
success=success,
error_message=error if not success else None,
printer_id=printer_id,
printer_name=printer_name,
)
if success:
logger.info(f"Sent notification via {provider.name}")
else:
@@ -392,6 +524,17 @@ class NotificationService:
except Exception as e:
logger.exception(f"Error sending notification via {provider.name}")
await self._update_provider_status(db, provider.id, False, str(e))
await self._log_notification(
db=db,
provider_id=provider.id,
event_type=event_type,
title=title,
message=message,
success=False,
error_message=str(e),
printer_id=printer_id,
printer_name=printer_name,
)
async def on_print_start(
self, printer_id: int, printer_name: str, data: dict, db: AsyncSession
@@ -415,7 +558,7 @@ class NotificationService:
logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
title, message = await self._build_message_from_template(db, "print_start", variables)
await self._send_to_providers(providers, title, message, db)
await self._send_to_providers(providers, title, message, db, "print_start", printer_id, printer_name)
async def on_print_complete(
self,
@@ -469,7 +612,7 @@ class NotificationService:
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)
await self._send_to_providers(providers, title, message, db, event_type, printer_id, printer_name)
async def on_print_progress(
self,
@@ -493,7 +636,7 @@ class NotificationService:
}
title, message = await self._build_message_from_template(db, "print_progress", variables)
await self._send_to_providers(providers, title, message, db)
await self._send_to_providers(providers, title, message, db, "print_progress", printer_id, printer_name)
async def on_printer_offline(
self, printer_id: int, printer_name: str, db: AsyncSession
@@ -506,7 +649,7 @@ class NotificationService:
variables = {"printer": printer_name}
title, message = await self._build_message_from_template(db, "printer_offline", variables)
await self._send_to_providers(providers, title, message, db)
await self._send_to_providers(providers, title, message, db, "printer_offline", printer_id, printer_name)
async def on_printer_error(
self,
@@ -528,7 +671,7 @@ class NotificationService:
}
title, message = await self._build_message_from_template(db, "printer_error", variables)
await self._send_to_providers(providers, title, message, db)
await self._send_to_providers(providers, title, message, db, "printer_error", printer_id, printer_name)
async def on_filament_low(
self,
@@ -552,7 +695,7 @@ class NotificationService:
}
title, message = await self._build_message_from_template(db, "filament_low", variables)
await self._send_to_providers(providers, title, message, db)
await self._send_to_providers(providers, title, message, db, "filament_low", printer_id, printer_name)
async def on_maintenance_due(
self,
@@ -584,12 +727,163 @@ class NotificationService:
logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}")
title, message = await self._build_message_from_template(db, "maintenance_due", variables)
await self._send_to_providers(providers, title, message, db)
await self._send_to_providers(providers, title, message, db, "maintenance_due", printer_id, printer_name)
def clear_template_cache(self):
"""Clear the template cache. Call this when templates are updated."""
self._template_cache.clear()
async def _queue_for_digest(
self,
provider: NotificationProvider,
event_type: str,
title: str,
message: str,
db: AsyncSession,
printer_id: int | None = None,
printer_name: str | None = None,
):
"""Queue a notification for later delivery in the daily digest."""
try:
queue_entry = NotificationDigestQueue(
provider_id=provider.id,
event_type=event_type,
title=title,
message=message,
printer_id=printer_id,
printer_name=printer_name,
)
db.add(queue_entry)
await db.commit()
logger.info(f"Queued notification for digest: {event_type} for provider {provider.name}")
except Exception as e:
logger.warning(f"Failed to queue notification for digest: {e}")
async def send_digest(self, provider_id: int):
"""Send all queued notifications as a single digest for a provider."""
from backend.app.core.database import async_session
async with async_session() as db:
# Get the provider
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == provider_id)
)
provider = result.scalar_one_or_none()
if not provider or not provider.enabled:
return
# Get all queued notifications for this provider
result = await db.execute(
select(NotificationDigestQueue)
.where(NotificationDigestQueue.provider_id == provider_id)
.order_by(NotificationDigestQueue.created_at)
)
queue_entries = list(result.scalars().all())
if not queue_entries:
logger.debug(f"No queued notifications for provider {provider.name}")
return
# Build digest message
title = f"Daily Digest - {len(queue_entries)} Events"
# Group by event type
events_by_type: dict[str, list] = {}
for entry in queue_entries:
if entry.event_type not in events_by_type:
events_by_type[entry.event_type] = []
events_by_type[entry.event_type].append(entry)
# Format the digest body
body_parts = []
for event_type, entries in events_by_type.items():
event_label = event_type.replace("_", " ").title()
body_parts.append(f"== {event_label} ({len(entries)}) ==")
for entry in entries:
time_str = entry.created_at.strftime("%H:%M")
printer_info = f"[{entry.printer_name}] " if entry.printer_name else ""
body_parts.append(f" {time_str} {printer_info}{entry.title}")
body_parts.append("")
body = "\n".join(body_parts)
# Send the digest
success, error = await self._send_to_provider(provider, title, body)
# Log the digest
await self._log_notification(
db=db,
provider_id=provider.id,
event_type="daily_digest",
title=title,
message=body,
success=success,
error_message=error if not success else None,
)
# Clear the queue
for entry in queue_entries:
await db.delete(entry)
await db.commit()
if success:
logger.info(f"Sent daily digest with {len(queue_entries)} events to {provider.name}")
else:
logger.warning(f"Failed to send daily digest to {provider.name}: {error}")
async def check_and_send_digests(self):
"""Check all providers and send digests if it's their scheduled time."""
from backend.app.core.database import async_session
current_time = datetime.now().strftime("%H:%M")
# Avoid duplicate checks within the same minute
if current_time == self._last_digest_check:
return
self._last_digest_check = current_time
async with async_session() as db:
# Find all providers with digest enabled at this time
result = await db.execute(
select(NotificationProvider).where(
NotificationProvider.enabled == True,
NotificationProvider.daily_digest_enabled == True,
NotificationProvider.daily_digest_time == current_time,
)
)
providers = result.scalars().all()
for provider in providers:
try:
await self.send_digest(provider.id)
except Exception as e:
logger.error(f"Error sending digest for provider {provider.id}: {e}")
def start_digest_scheduler(self):
"""Start the background scheduler for daily digest notifications."""
if self._digest_scheduler_task is None:
self._digest_scheduler_task = asyncio.create_task(self._digest_scheduler_loop())
logger.info("Notification digest scheduler started")
def stop_digest_scheduler(self):
"""Stop the background scheduler for daily digests."""
if self._digest_scheduler_task:
self._digest_scheduler_task.cancel()
self._digest_scheduler_task = None
logger.info("Notification digest scheduler stopped")
async def _digest_scheduler_loop(self):
"""Background loop that checks for scheduled digests every minute."""
while True:
try:
await self.check_and_send_digests()
except Exception as e:
logger.error(f"Error in digest scheduler: {e}")
# Wait until the next minute
await asyncio.sleep(60)
# Global instance
notification_service = NotificationService()
@@ -23,11 +23,81 @@ class SmartPlugManager:
def __init__(self):
self._pending_off: dict[int, asyncio.Task] = {} # plug_id -> task
self._loop: asyncio.AbstractEventLoop | None = None
self._scheduler_task: asyncio.Task | None = None
self._last_schedule_check: dict[int, str] = {} # plug_id -> "HH:MM" last executed
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
"""Set the event loop for async operations."""
self._loop = loop
def start_scheduler(self):
"""Start the background scheduler for time-based plug control."""
if self._scheduler_task is None:
self._scheduler_task = asyncio.create_task(self._schedule_loop())
logger.info("Smart plug scheduler started")
def stop_scheduler(self):
"""Stop the background scheduler."""
if self._scheduler_task:
self._scheduler_task.cancel()
self._scheduler_task = None
logger.info("Smart plug scheduler stopped")
async def _schedule_loop(self):
"""Background loop that checks scheduled on/off times every minute."""
while True:
try:
await self._check_schedules()
except Exception as e:
logger.error(f"Error in schedule check: {e}")
# Wait until the next minute
await asyncio.sleep(60)
async def _check_schedules(self):
"""Check all plugs for scheduled on/off times."""
from backend.app.core.database import async_session
from backend.app.models.smart_plug import SmartPlug
current_time = datetime.now().strftime("%H:%M")
async with async_session() as db:
result = await db.execute(
select(SmartPlug).where(
SmartPlug.enabled == True,
SmartPlug.schedule_enabled == True,
)
)
plugs = result.scalars().all()
for plug in plugs:
# Check if we should turn on
if plug.schedule_on_time == current_time:
last_check = self._last_schedule_check.get(plug.id)
if last_check != f"on:{current_time}":
logger.info(f"Schedule: Turning on plug '{plug.name}' at {current_time}")
success = await tasmota_service.turn_on(plug)
if success:
plug.last_state = "ON"
plug.last_checked = datetime.utcnow()
self._last_schedule_check[plug.id] = f"on:{current_time}"
# Check if we should turn off
if plug.schedule_off_time == current_time:
last_check = self._last_schedule_check.get(plug.id)
if last_check != f"off:{current_time}":
logger.info(f"Schedule: Turning off plug '{plug.name}' at {current_time}")
success = await tasmota_service.turn_off(plug)
if success:
plug.last_state = "OFF"
plug.last_checked = datetime.utcnow()
self._last_schedule_check[plug.id] = f"off:{current_time}"
# Mark printer offline if linked
if plug.printer_id:
printer_manager.mark_printer_offline(plug.printer_id)
await db.commit()
async def _get_plug_for_printer(
self, printer_id: int, db: AsyncSession
) -> "SmartPlug | None":
+121 -1
View File
@@ -252,6 +252,11 @@ export interface AppSettings {
ams_humidity_fair: number; // <= this is orange, > is red
ams_temp_good: number; // <= this is green/blue
ams_temp_fair: number; // <= this is orange, > is red
// Date/time format settings
date_format: 'system' | 'us' | 'eu' | 'iso';
time_format: 'system' | '12h' | '24h';
// Default printer
default_printer_id: number | null;
}
export type AppSettingsUpdate = Partial<AppSettings>;
@@ -362,6 +367,16 @@ export interface SmartPlug {
off_temp_threshold: number;
username: string | null;
password: string | null;
// Power alerts
power_alert_enabled: boolean;
power_alert_high: number | null;
power_alert_low: number | null;
power_alert_last_triggered: string | null;
// Schedule
schedule_enabled: boolean;
schedule_on_time: string | null;
schedule_off_time: string | null;
// Status
last_state: string | null;
last_checked: string | null;
auto_off_executed: boolean; // True when auto-off was triggered after print
@@ -381,6 +396,14 @@ export interface SmartPlugCreate {
off_temp_threshold?: number;
username?: string | null;
password?: string | null;
// Power alerts
power_alert_enabled?: boolean;
power_alert_high?: number | null;
power_alert_low?: number | null;
// Schedule
schedule_enabled?: boolean;
schedule_on_time?: string | null;
schedule_off_time?: string | null;
}
export interface SmartPlugUpdate {
@@ -395,6 +418,14 @@ export interface SmartPlugUpdate {
off_temp_threshold?: number;
username?: string | null;
password?: string | null;
// Power alerts
power_alert_enabled?: boolean;
power_alert_high?: number | null;
power_alert_low?: number | null;
// Schedule
schedule_enabled?: boolean;
schedule_on_time?: string | null;
schedule_off_time?: string | null;
}
export interface SmartPlugEnergy {
@@ -552,7 +583,7 @@ export interface Filament {
}
// Notification Provider types
export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email';
export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook';
export interface NotificationProvider {
id: number;
@@ -575,6 +606,9 @@ export interface NotificationProvider {
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
// Daily digest
daily_digest_enabled: boolean;
daily_digest_time: string | null;
// Printer filter
printer_id: number | null;
// Status tracking
@@ -606,6 +640,9 @@ export interface NotificationProviderCreate {
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
quiet_hours_end?: string | null;
// Daily digest
daily_digest_enabled?: boolean;
daily_digest_time?: string | null;
// Printer filter
printer_id?: number | null;
}
@@ -630,6 +667,9 @@ export interface NotificationProviderUpdate {
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
quiet_hours_end?: string | null;
// Daily digest
daily_digest_enabled?: boolean;
daily_digest_time?: string | null;
// Printer filter
printer_id?: number | null;
}
@@ -711,6 +751,30 @@ export interface TemplatePreviewResponse {
body: string;
}
// Notification Log types
export interface NotificationLogEntry {
id: number;
provider_id: number;
provider_name: string | null;
provider_type: string | null;
event_type: string;
title: string;
message: string;
success: boolean;
error_message: string | null;
printer_id: number | null;
printer_name: string | null;
created_at: string;
}
export interface NotificationLogStats {
total: number;
success_count: number;
failure_count: number;
by_event_type: Record<string, number>;
by_provider: Record<string, number>;
}
// Spoolman types
export interface SpoolmanStatus {
enabled: boolean;
@@ -1085,6 +1149,23 @@ export const api = {
}),
resetSettings: () =>
request<AppSettings>('/settings/reset', { method: 'POST' }),
exportBackup: async () => {
const response = await fetch(`${API_BASE}/settings/backup`);
return response.json();
},
importBackup: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/settings/restore`, {
method: 'POST',
body: formData,
});
return response.json() as Promise<{
success: boolean;
message: string;
restored?: { settings: number; notification_providers: number; smart_plugs: number };
}>;
},
checkFfmpeg: () =>
request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
@@ -1263,6 +1344,19 @@ export const api = {
method: 'POST',
body: JSON.stringify(data),
}),
testAllNotificationProviders: () =>
request<{
tested: number;
success: number;
failed: number;
results: Array<{
provider_id: number;
provider_name: string;
provider_type: string;
success: boolean;
message: string;
}>;
}>('/notifications/test-all', { method: 'POST' }),
// Notification Templates
getNotificationTemplates: () => request<NotificationTemplate[]>('/notification-templates'),
@@ -1283,6 +1377,32 @@ export const api = {
body: JSON.stringify(data),
}),
// Notification Logs
getNotificationLogs: (params?: {
limit?: number;
offset?: number;
provider_id?: number;
event_type?: string;
success?: boolean;
days?: number;
}) => {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset) searchParams.set('offset', String(params.offset));
if (params?.provider_id) searchParams.set('provider_id', String(params.provider_id));
if (params?.event_type) searchParams.set('event_type', params.event_type);
if (params?.success !== undefined) searchParams.set('success', String(params.success));
if (params?.days) searchParams.set('days', String(params.days));
return request<NotificationLogEntry[]>(`/notifications/logs?${searchParams}`);
},
getNotificationLogStats: (days = 7) =>
request<NotificationLogStats>(`/notifications/logs/stats?days=${days}`),
clearNotificationLogs: (olderThanDays = 30) =>
request<{ deleted: number; message: string }>(
`/notifications/logs?older_than_days=${olderThanDays}`,
{ method: 'DELETE' }
),
// Spoolman Integration
getSpoolmanStatus: () => request<SpoolmanStatus>('/spoolman/status'),
connectSpoolman: () =>
@@ -12,11 +12,13 @@ interface AddNotificationModalProps {
}
const PROVIDER_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
{ value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
{ value: 'discord', label: 'Discord', description: 'Send to Discord channel via webhook' },
{ value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
{ value: 'ntfy', label: 'ntfy', description: 'Free, self-hostable push notifications' },
{ value: 'pushover', label: 'Pushover', description: 'Simple, reliable push notifications' },
{ value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
{ value: 'email', label: 'Email', description: 'SMTP email notifications' },
{ value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
{ value: 'webhook', label: 'Webhook', description: 'Generic HTTP POST to any URL' },
];
export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
@@ -24,12 +26,16 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
const isEditing = !!provider;
const [name, setName] = useState(provider?.name || '');
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'ntfy');
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'discord');
const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
// Daily digest
const [dailyDigestEnabled, setDailyDigestEnabled] = useState(provider?.daily_digest_enabled || false);
const [dailyDigestTime, setDailyDigestTime] = useState(provider?.daily_digest_time || '08:00');
// Event toggles
const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false);
const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true);
@@ -126,6 +132,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
quiet_hours_enabled: quietHoursEnabled,
quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
// Daily digest
daily_digest_enabled: dailyDigestEnabled,
daily_digest_time: dailyDigestEnabled ? dailyDigestTime : null,
// Event toggles
on_print_start: onPrintStart,
on_print_complete: onPrintComplete,
@@ -190,6 +199,17 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
{ key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true },
{ key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true },
];
case 'discord':
return [
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'text', required: true },
];
case 'webhook':
return [
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'text', required: true },
{ key: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false },
{ key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false },
{ key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false },
];
default:
return [];
}
@@ -401,6 +421,34 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
)}
</div>
{/* Daily Digest */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<label className="text-sm text-white">Daily Digest</label>
<p className="text-xs text-bambu-gray">Batch notifications into a single daily summary</p>
</div>
<Toggle
checked={dailyDigestEnabled}
onChange={setDailyDigestEnabled}
/>
</div>
{dailyDigestEnabled && (
<div>
<label className="block text-xs text-bambu-gray mb-1">Send digest at</label>
<input
type="time"
value={dailyDigestTime}
onChange={(e) => setDailyDigestTime(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
<p className="text-xs text-bambu-gray mt-1">
Events will be collected and sent as a single summary at this time
</p>
</div>
)}
</div>
{/* Event Toggles */}
<div className="space-y-3">
<p className="text-sm text-bambu-gray">Notification Events</p>
+117 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle } from 'lucide-react';
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock } from 'lucide-react';
import { api } from '../api/client';
import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
import { Button } from './Button';
@@ -22,6 +22,16 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
const [error, setError] = useState<string | null>(null);
// Power alert settings
const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
const [powerAlertLow, setPowerAlertLow] = useState<string>(plug?.power_alert_low?.toString() || '');
// Schedule settings
const [scheduleEnabled, setScheduleEnabled] = useState(plug?.schedule_enabled || false);
const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
// Fetch printers for linking
const { data: printers } = useQuery({
queryKey: ['printers'],
@@ -109,6 +119,14 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
username: username.trim() || null,
password: password.trim() || null,
printer_id: printerId,
// Power alerts
power_alert_enabled: powerAlertEnabled,
power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
power_alert_low: powerAlertLow ? parseFloat(powerAlertLow) : null,
// Schedule
schedule_enabled: scheduleEnabled,
schedule_on_time: scheduleOnTime || null,
schedule_off_time: scheduleOffTime || null,
};
if (isEditing) {
@@ -266,6 +284,104 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</p>
</div>
{/* Power Alerts */}
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4 text-bambu-green" />
<span className="text-white font-medium">Power Alerts</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={powerAlertEnabled}
onChange={(e) => setPowerAlertEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{powerAlertEnabled && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">Alert if above (W)</label>
<input
type="number"
value={powerAlertHigh}
onChange={(e) => setPowerAlertHigh(e.target.value)}
placeholder="e.g. 200"
min="0"
max="5000"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Alert if below (W)</label>
<input
type="number"
value={powerAlertLow}
onChange={(e) => setPowerAlertLow(e.target.value)}
placeholder="e.g. 10"
min="0"
max="5000"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.
</p>
</div>
)}
</div>
{/* Schedule */}
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-green" />
<span className="text-white font-medium">Daily Schedule</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={scheduleEnabled}
onChange={(e) => setScheduleEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{scheduleEnabled && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
<input
type="time"
value={scheduleOnTime}
onChange={(e) => setScheduleOnTime(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
<input
type="time"
value={scheduleOffTime}
onChange={(e) => setScheduleOffTime(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
</p>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
@@ -0,0 +1,287 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { History, CheckCircle, XCircle, Loader2, Trash2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
import { api } from '../api/client';
import type { NotificationLogEntry } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
const EVENT_LABELS: Record<string, string> = {
print_start: 'Print Started',
print_complete: 'Print Complete',
print_failed: 'Print Failed',
print_stopped: 'Print Stopped',
print_progress: 'Progress',
printer_offline: 'Printer Offline',
printer_error: 'Printer Error',
filament_low: 'Low Filament',
maintenance_due: 'Maintenance Due',
test: 'Test',
};
const EVENT_COLORS: Record<string, string> = {
print_start: 'text-blue-400',
print_complete: 'text-bambu-green',
print_failed: 'text-red-400',
print_stopped: 'text-orange-400',
print_progress: 'text-yellow-400',
printer_offline: 'text-gray-400',
printer_error: 'text-rose-400',
filament_low: 'text-cyan-400',
maintenance_due: 'text-purple-400',
test: 'text-bambu-gray',
};
interface NotificationLogViewerProps {
onClose: () => void;
}
export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [days, setDays] = useState(7);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [showFailedOnly, setShowFailedOnly] = useState(false);
const { data: logs, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['notification-logs', days, showFailedOnly],
queryFn: () => api.getNotificationLogs({
days,
limit: 100,
success: showFailedOnly ? false : undefined,
}),
});
const { data: stats } = useQuery({
queryKey: ['notification-log-stats', days],
queryFn: () => api.getNotificationLogStats(days),
});
const clearMutation = useMutation({
mutationFn: () => api.clearNotificationLogs(30),
onSuccess: (data) => {
showToast(data.message, 'success');
queryClient.invalidateQueries({ queryKey: ['notification-logs'] });
queryClient.invalidateQueries({ queryKey: ['notification-log-stats'] });
},
onError: (error: Error) => {
showToast(`Failed to clear logs: ${error.message}`, 'error');
},
});
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg w-full max-w-3xl max-h-[85vh] flex flex-col">
{/* Header */}
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
<div className="flex items-center gap-3">
<History className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Notification Log</h2>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
&times;
</button>
</div>
{/* Stats Bar */}
{stats && (
<div className="px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark/50">
<div className="flex items-center gap-6 text-sm">
<span className="text-bambu-gray">
Last {days} days: <span className="text-white font-medium">{stats.total}</span> notifications
</span>
<span className="flex items-center gap-1 text-bambu-green">
<CheckCircle className="w-4 h-4" />
{stats.success_count} sent
</span>
{stats.failure_count > 0 && (
<span className="flex items-center gap-1 text-red-400">
<XCircle className="w-4 h-4" />
{stats.failure_count} failed
</span>
)}
</div>
</div>
)}
{/* Filters */}
<div className="px-4 py-3 border-b border-bambu-dark-tertiary flex items-center gap-4">
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-bambu-green"
>
<option value={1}>Last 24 hours</option>
<option value={7}>Last 7 days</option>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
</select>
<label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
<input
type="checkbox"
checked={showFailedOnly}
onChange={(e) => setShowFailedOnly(e.target.checked)}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
Show failed only
</label>
<div className="flex-1" />
<Button
size="sm"
variant="secondary"
onClick={() => refetch()}
disabled={isRefetching}
>
{isRefetching ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Refresh
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => clearMutation.mutate()}
disabled={clearMutation.isPending}
className="text-red-400 hover:text-red-300"
>
{clearMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Clear Old
</Button>
</div>
{/* Log List */}
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : logs && logs.length > 0 ? (
<div className="space-y-2">
{logs.map((log) => (
<LogEntry
key={log.id}
log={log}
isExpanded={expandedId === log.id}
onToggle={() => setExpandedId(expandedId === log.id ? null : log.id)}
formatDate={formatDate}
/>
))}
</div>
) : (
<div className="text-center py-12 text-bambu-gray">
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">
{showFailedOnly ? 'No failed notifications' : 'No notifications logged'}
</p>
</div>
)}
</div>
</div>
</div>
);
}
function LogEntry({
log,
isExpanded,
onToggle,
formatDate,
}: {
log: NotificationLogEntry;
isExpanded: boolean;
onToggle: () => void;
formatDate: (date: string) => string;
}) {
return (
<div
className={`border rounded-lg overflow-hidden transition-colors ${
log.success
? 'border-bambu-dark-tertiary bg-bambu-dark/30'
: 'border-red-500/30 bg-red-500/5'
}`}
>
<button
className="w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-bambu-dark/50 transition-colors"
onClick={onToggle}
>
{log.success ? (
<CheckCircle className="w-4 h-4 text-bambu-green shrink-0" />
) : (
<XCircle className="w-4 h-4 text-red-400 shrink-0" />
)}
<span className={`text-xs font-medium ${EVENT_COLORS[log.event_type] || 'text-bambu-gray'}`}>
{EVENT_LABELS[log.event_type] || log.event_type}
</span>
<span className="text-sm text-white truncate flex-1">
{log.provider_name || 'Unknown Provider'}
</span>
{log.printer_name && (
<span className="text-xs text-bambu-gray">
{log.printer_name}
</span>
)}
<span className="text-xs text-bambu-gray shrink-0">
{formatDate(log.created_at)}
</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-bambu-gray shrink-0" />
) : (
<ChevronDown className="w-4 h-4 text-bambu-gray shrink-0" />
)}
</button>
{isExpanded && (
<div className="px-3 py-2 border-t border-bambu-dark-tertiary bg-bambu-dark/20 space-y-2">
<div>
<p className="text-xs text-bambu-gray mb-1">Title</p>
<p className="text-sm text-white">{log.title}</p>
</div>
<div>
<p className="text-xs text-bambu-gray mb-1">Message</p>
<p className="text-sm text-white whitespace-pre-wrap">{log.message}</p>
</div>
{!log.success && log.error_message && (
<div>
<p className="text-xs text-red-400 mb-1">Error</p>
<p className="text-sm text-red-300">{log.error_message}</p>
</div>
)}
<div className="flex gap-4 text-xs text-bambu-gray pt-1">
<span>Provider: {log.provider_type}</span>
<span>Time: {new Date(log.created_at).toLocaleString()}</span>
</div>
</div>
)}
</div>
);
}
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp } from 'lucide-react';
import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp, Calendar } from 'lucide-react';
import { api } from '../api/client';
import type { NotificationProvider, NotificationProviderUpdate } from '../api/client';
import { Card, CardContent } from './Card';
@@ -19,6 +19,8 @@ const PROVIDER_LABELS: Record<string, string> = {
pushover: 'Pushover',
telegram: 'Telegram',
email: 'Email',
discord: 'Discord',
webhook: 'Webhook',
};
export function NotificationProviderCard({ provider, onEdit }: NotificationProviderCardProps) {
@@ -147,6 +149,12 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
Quiet
</span>
)}
{provider.daily_digest_enabled && (
<span className="px-2 py-0.5 bg-emerald-500/20 text-emerald-400 text-xs rounded flex items-center gap-1">
<Calendar className="w-3 h-3" />
Digest {provider.daily_digest_time}
</span>
)}
</div>
{/* Test Button */}
@@ -332,6 +340,33 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
)}
</div>
{/* Daily Digest */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-emerald-400" />
<p className="text-sm text-white">Daily Digest</p>
</div>
<Toggle
checked={provider.daily_digest_enabled}
onChange={(checked) => updateMutation.mutate({ daily_digest_enabled: checked })}
/>
</div>
{provider.daily_digest_enabled && (
<div className="pl-4 border-l-2 border-bambu-dark-tertiary space-y-2">
<p className="text-xs text-bambu-gray">Batch notifications into a single daily summary</p>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-gray" />
<span className="text-sm text-white">
Send at {formatTime(provider.daily_digest_time) || '08:00'}
</span>
</div>
<p className="text-xs text-bambu-gray">Edit provider to change digest time</p>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<Button
+23 -1
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2 } from 'lucide-react';
import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar } from 'lucide-react';
import { api } from '../api/client';
import type { SmartPlug, SmartPlugUpdate } from '../api/client';
import { Card, CardContent } from './Card';
@@ -108,6 +108,28 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
</div>
)}
{/* Feature Badges */}
{(plug.power_alert_enabled || plug.schedule_enabled) && (
<div className="flex flex-wrap gap-1.5 mb-3">
{plug.power_alert_enabled && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
<Bell className="w-3 h-3" />
Alerts
</span>
)}
{plug.schedule_enabled && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded-full">
<Calendar className="w-3 h-3" />
{plug.schedule_on_time && plug.schedule_off_time
? `${plug.schedule_on_time} - ${plug.schedule_off_time}`
: plug.schedule_on_time
? `On ${plug.schedule_on_time}`
: `Off ${plug.schedule_off_time}`}
</span>
)}
</div>
)}
{/* Quick Controls */}
<div className="flex gap-2 mb-3">
<Button
+632 -86
View File
@@ -1,8 +1,8 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2 } from 'lucide-react';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { AppSettings, SmartPlug, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
@@ -10,6 +10,8 @@ import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
import { NotificationProviderCard } from '../components/NotificationProviderCard';
import { AddNotificationModal } from '../components/AddNotificationModal';
import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
import { NotificationLogViewer } from '../components/NotificationLogViewer';
import { ConfirmModal } from '../components/ConfirmModal';
import { SpoolmanSettings } from '../components/SpoolmanSettings';
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
import { availableLanguages } from '../i18n';
@@ -26,9 +28,16 @@ export function SettingsPage() {
const [showNotificationModal, setShowNotificationModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
const [showLogViewer, setShowLogViewer] = useState(false);
const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
const fileInputRef = useRef<HTMLInputElement>(null);
const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications'>('general');
// Confirm modal states
const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
const handleDefaultViewChange = (path: string) => {
setDefaultViewState(path);
setDefaultView(path);
@@ -49,11 +58,62 @@ export function SettingsPage() {
queryFn: api.getSmartPlugs,
});
// Fetch energy data for all smart plugs when on the plugs tab
const { data: plugEnergySummary, isLoading: energyLoading } = useQuery({
queryKey: ['smart-plugs-energy', smartPlugs?.map(p => p.id)],
queryFn: async () => {
if (!smartPlugs || smartPlugs.length === 0) return null;
const statuses = await Promise.all(
smartPlugs.filter(p => p.enabled).map(async (plug) => {
try {
const status = await api.getSmartPlugStatus(plug.id);
return { plug, status };
} catch {
return { plug, status: null as SmartPlugStatus | null };
}
})
);
// Aggregate energy data
let totalPower = 0;
let totalToday = 0;
let totalYesterday = 0;
let totalLifetime = 0;
let reachableCount = 0;
for (const { status } of statuses) {
if (status?.reachable && status.energy) {
reachableCount++;
if (status.energy.power != null) totalPower += status.energy.power;
if (status.energy.today != null) totalToday += status.energy.today;
if (status.energy.yesterday != null) totalYesterday += status.energy.yesterday;
if (status.energy.total != null) totalLifetime += status.energy.total;
}
}
return {
totalPower,
totalToday,
totalYesterday,
totalLifetime,
reachableCount,
totalPlugs: smartPlugs.filter(p => p.enabled).length,
};
},
enabled: activeTab === 'plugs' && !!smartPlugs && smartPlugs.length > 0,
refetchInterval: activeTab === 'plugs' ? 10000 : false, // Refresh every 10s when on plugs tab
});
const { data: notificationProviders, isLoading: providersLoading } = useQuery({
queryKey: ['notification-providers'],
queryFn: api.getNotificationProviders,
});
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const { data: notificationTemplates, isLoading: templatesLoading } = useQuery({
queryKey: ['notification-templates'],
queryFn: api.getNotificationTemplates,
@@ -95,6 +155,70 @@ export function SettingsPage() {
},
});
// Test all notification providers
const [testAllResult, setTestAllResult] = useState<{
tested: number;
success: number;
failed: number;
results: Array<{
provider_id: number;
provider_name: string;
provider_type: string;
success: boolean;
message: string;
}>;
} | null>(null);
const testAllMutation = useMutation({
mutationFn: api.testAllNotificationProviders,
onSuccess: (data) => {
setTestAllResult(data);
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
if (data.failed === 0) {
showToast(`All ${data.tested} providers tested successfully!`, 'success');
} else {
showToast(`${data.success}/${data.tested} providers succeeded`, data.failed > 0 ? 'error' : 'success');
}
},
onError: (error: Error) => {
showToast(`Failed to test providers: ${error.message}`, 'error');
},
});
// Bulk action for smart plugs
const bulkPlugActionMutation = useMutation({
mutationFn: async (action: 'on' | 'off') => {
if (!smartPlugs) return { success: 0, failed: 0 };
const enabledPlugs = smartPlugs.filter(p => p.enabled);
const results = await Promise.all(
enabledPlugs.map(async (plug) => {
try {
await api.controlSmartPlug(plug.id, action);
return { success: true };
} catch {
return { success: false };
}
})
);
return {
success: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
};
},
onSuccess: (data, action) => {
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
queryClient.invalidateQueries({ queryKey: ['smart-plugs-energy'] });
if (data.failed === 0) {
showToast(`All ${data.success} plugs turned ${action}`, 'success');
} else {
showToast(`${data.success} plugs turned ${action}, ${data.failed} failed`, 'error');
}
},
onError: (error: Error) => {
showToast(`Failed: ${error.message}`, 'error');
},
});
// Ref for debounce timeout
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isInitialLoadRef = useRef(true);
@@ -144,7 +268,10 @@ export function SettingsPage() {
settings.ams_humidity_good !== localSettings.ams_humidity_good ||
settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
settings.ams_temp_good !== localSettings.ams_temp_good ||
settings.ams_temp_fair !== localSettings.ams_temp_fair;
settings.ams_temp_fair !== localSettings.ams_temp_fair ||
settings.date_format !== localSettings.date_format ||
settings.time_format !== localSettings.time_format ||
settings.default_printer_id !== localSettings.default_printer_id;
if (!hasChanges) {
return;
@@ -238,6 +365,120 @@ export function SettingsPage() {
<div className="flex gap-8">
{/* Left Column - General Settings */}
<div className="space-y-6 flex-1 max-w-xl">
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Globe className="w-4 h-4 inline mr-1" />
{t('settings.language')}
</label>
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{availableLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.nativeName} ({lang.name})
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.languageDescription')}
</p>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('settings.defaultView')}
</label>
<select
value={defaultView}
onChange={(e) => handleDefaultViewChange(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{defaultNavItems.map((item) => (
<option key={item.id} value={item.to}>
{t(item.labelKey)}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.defaultViewDescription')}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">
Date Format
</label>
<select
value={localSettings.date_format || 'system'}
onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="system">System Default</option>
<option value="us">US (MM/DD/YYYY)</option>
<option value="eu">EU (DD/MM/YYYY)</option>
<option value="iso">ISO (YYYY-MM-DD)</option>
</select>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
Time Format
</label>
<select
value={localSettings.time_format || 'system'}
onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="system">System Default</option>
<option value="12h">12-hour (3:30 PM)</option>
<option value="24h">24-hour (15:30)</option>
</select>
</div>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
Default Printer
</label>
<select
value={localSettings.default_printer_id ?? ''}
onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">No default (ask each time)</option>
{printers?.map((printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
Pre-select this printer for uploads, reprints, and other operations.
</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">Sidebar order</p>
<p className="text-sm text-bambu-gray">
Drag items in the sidebar to reorder. Reset to default order here.
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleResetSidebarOrder}
>
<RotateCcw className="w-4 h-4" />
Reset
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">Archive Settings</h2>
@@ -382,69 +623,6 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">
<Globe className="w-4 h-4 inline mr-1" />
{t('settings.language')}
</label>
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{availableLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.nativeName} ({lang.name})
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.languageDescription')}
</p>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('settings.defaultView')}
</label>
<select
value={defaultView}
onChange={(e) => handleDefaultViewChange(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{defaultNavItems.map((item) => (
<option key={item.id} value={item.to}>
{t(item.labelKey)}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.defaultViewDescription')}
</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">Sidebar order</p>
<p className="text-sm text-bambu-gray">
Drag items in the sidebar to reorder. Reset to default order here.
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleResetSidebarOrder}
>
<RotateCcw className="w-4 h-4" />
Reset
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Second Column - AMS & Spoolman */}
@@ -678,6 +856,121 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
{/* Data Management */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">Data Management</h2>
</CardHeader>
<CardContent className="space-y-4">
{/* Backup/Restore */}
<div className="flex items-center justify-between">
<div>
<p className="text-white">Backup Settings</p>
<p className="text-sm text-bambu-gray">
Export settings, providers, and plugs to JSON
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={async () => {
try {
const backup = await api.exportBackup();
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bambutrack-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
showToast('Backup downloaded', 'success');
} catch (err) {
showToast('Failed to create backup', 'error');
}
}}
>
<Download className="w-4 h-4" />
Export
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">Restore Settings</p>
<p className="text-sm text-bambu-gray">
Import settings from a backup file
</p>
</div>
<div>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const result = await api.importBackup(file);
if (result.success) {
showToast(result.message, 'success');
queryClient.invalidateQueries();
} else {
showToast(result.message, 'error');
}
} catch (err) {
showToast('Failed to restore backup', 'error');
}
e.target.value = '';
}}
/>
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4" />
Import
</Button>
</div>
</div>
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-white">Clear Notification Logs</p>
<p className="text-sm text-bambu-gray">
Delete notification logs older than 30 days
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowClearLogsConfirm(true)}
>
<Trash2 className="w-4 h-4" />
Clear
</Button>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-white">Clear Local Storage</p>
<p className="text-sm text-bambu-gray">
Clear browser cache (sidebar order, preferences)
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowClearStorageConfirm(true)}
>
<Trash2 className="w-4 h-4" />
Clear
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)}
@@ -685,7 +978,7 @@ export function SettingsPage() {
{/* Smart Plugs Tab */}
{activeTab === 'plugs' && (
<div className="max-w-4xl">
<div className="flex items-center justify-between mb-6">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Plug className="w-5 h-5 text-bambu-green" />
@@ -695,17 +988,144 @@ export function SettingsPage() {
Connect Tasmota-based smart plugs to automate power control and track energy usage for your printers.
</p>
</div>
<Button
onClick={() => {
setEditingPlug(null);
setShowPlugModal(true);
}}
>
<Plus className="w-4 h-4" />
Add Smart Plug
</Button>
<div className="flex items-center gap-2 pt-1 shrink-0">
{smartPlugs && smartPlugs.filter(p => p.enabled).length > 1 && (
<>
<Button
variant="secondary"
size="sm"
className="whitespace-nowrap"
onClick={() => setShowBulkPlugConfirm('on')}
disabled={bulkPlugActionMutation.isPending}
title="Turn all plugs on"
>
{bulkPlugActionMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Power className="w-4 h-4 text-bambu-green" />
)}
All On
</Button>
<Button
variant="secondary"
size="sm"
className="whitespace-nowrap"
onClick={() => setShowBulkPlugConfirm('off')}
disabled={bulkPlugActionMutation.isPending}
title="Turn all plugs off"
>
{bulkPlugActionMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<PowerOff className="w-4 h-4 text-red-400" />
)}
All Off
</Button>
</>
)}
<Button
className="whitespace-nowrap"
onClick={() => {
setEditingPlug(null);
setShowPlugModal(true);
}}
>
<Plus className="w-4 h-4" />
Add Smart Plug
</Button>
</div>
</div>
{/* Energy Summary Card */}
{smartPlugs && smartPlugs.length > 0 && (
<Card className="mb-6">
<CardHeader>
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Zap className="w-4 h-4 text-yellow-400" />
Energy Summary
{energyLoading && (
<Loader2 className="w-4 h-4 animate-spin text-bambu-gray ml-2" />
)}
</h3>
</CardHeader>
<CardContent>
{plugEnergySummary ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Current Power */}
<div className="bg-bambu-dark rounded-lg p-3">
<div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
<Zap className="w-3 h-3" />
Current Power
</div>
<div className="text-xl font-bold text-white">
{plugEnergySummary.totalPower.toFixed(1)}
<span className="text-sm font-normal text-bambu-gray ml-1">W</span>
</div>
<div className="text-xs text-bambu-gray mt-1">
{plugEnergySummary.reachableCount}/{plugEnergySummary.totalPlugs} plugs online
</div>
</div>
{/* Today */}
<div className="bg-bambu-dark rounded-lg p-3">
<div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
<Calendar className="w-3 h-3" />
Today
</div>
<div className="text-xl font-bold text-white">
{plugEnergySummary.totalToday.toFixed(2)}
<span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
</div>
{localSettings && localSettings.energy_cost_per_kwh > 0 && (
<div className="text-xs text-bambu-gray mt-1">
~{(plugEnergySummary.totalToday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
</div>
)}
</div>
{/* Yesterday */}
<div className="bg-bambu-dark rounded-lg p-3">
<div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
<TrendingUp className="w-3 h-3" />
Yesterday
</div>
<div className="text-xl font-bold text-white">
{plugEnergySummary.totalYesterday.toFixed(2)}
<span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
</div>
{localSettings && localSettings.energy_cost_per_kwh > 0 && (
<div className="text-xs text-bambu-gray mt-1">
~{(plugEnergySummary.totalYesterday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
</div>
)}
</div>
{/* Total Lifetime */}
<div className="bg-bambu-dark rounded-lg p-3">
<div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
<DollarSign className="w-3 h-3" />
Total
</div>
<div className="text-xl font-bold text-white">
{plugEnergySummary.totalLifetime.toFixed(1)}
<span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
</div>
{localSettings && localSettings.energy_cost_per_kwh > 0 && (
<div className="text-xs text-bambu-gray mt-1">
~{(plugEnergySummary.totalLifetime * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
</div>
)}
</div>
</div>
) : !energyLoading ? (
<p className="text-sm text-bambu-gray">
Enable plugs to see energy summary
</p>
) : null}
</CardContent>
</Card>
)}
{plugsLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
@@ -756,16 +1176,44 @@ export function SettingsPage() {
<Bell className="w-5 h-5 text-bambu-green" />
Providers
</h2>
<Button
size="sm"
onClick={() => {
setEditingProvider(null);
setShowNotificationModal(true);
}}
>
<Plus className="w-4 h-4" />
Add
</Button>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => setShowLogViewer(true)}
>
<History className="w-4 h-4" />
Log
</Button>
{notificationProviders && notificationProviders.length > 0 && (
<Button
size="sm"
variant="secondary"
onClick={() => {
setTestAllResult(null);
testAllMutation.mutate();
}}
disabled={testAllMutation.isPending}
>
{testAllMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Test All
</Button>
)}
<Button
size="sm"
onClick={() => {
setEditingProvider(null);
setShowNotificationModal(true);
}}
>
<Plus className="w-4 h-4" />
Add
</Button>
</div>
</div>
{/* Notification Language Setting */}
@@ -791,6 +1239,44 @@ export function SettingsPage() {
</CardContent>
</Card>
{/* Test All Results */}
{testAllResult && (
<Card className="mb-4">
<CardContent className="py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-white">Test Results</span>
<button
onClick={() => setTestAllResult(null)}
className="text-bambu-gray hover:text-white text-xs"
>
Dismiss
</button>
</div>
<div className="flex items-center gap-4 text-sm mb-2">
<span className="flex items-center gap-1 text-bambu-green">
<CheckCircle className="w-4 h-4" />
{testAllResult.success} passed
</span>
{testAllResult.failed > 0 && (
<span className="flex items-center gap-1 text-red-400">
<XCircle className="w-4 h-4" />
{testAllResult.failed} failed
</span>
)}
</div>
{testAllResult.results.filter(r => !r.success).length > 0 && (
<div className="space-y-1 mt-2 pt-2 border-t border-bambu-dark-tertiary">
{testAllResult.results.filter(r => !r.success).map((result) => (
<div key={result.provider_id} className="text-xs text-red-400">
<span className="font-medium">{result.provider_name}:</span> {result.message}
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{providersLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
@@ -918,6 +1404,66 @@ export function SettingsPage() {
onClose={() => setEditingTemplate(null)}
/>
)}
{/* Notification Log Viewer */}
{showLogViewer && (
<NotificationLogViewer
onClose={() => setShowLogViewer(false)}
/>
)}
{/* Confirm Modal: Clear Notification Logs */}
{showClearLogsConfirm && (
<ConfirmModal
title="Clear Notification Logs"
message="This will permanently delete all notification logs older than 30 days. This action cannot be undone."
confirmText="Clear Logs"
variant="warning"
onConfirm={async () => {
setShowClearLogsConfirm(false);
try {
const result = await api.clearNotificationLogs(30);
showToast(result.message, 'success');
} catch {
showToast('Failed to clear logs', 'error');
}
}}
onCancel={() => setShowClearLogsConfirm(false)}
/>
)}
{/* Confirm Modal: Clear Local Storage */}
{showClearStorageConfirm && (
<ConfirmModal
title="Clear All Local Storage"
message="WARNING: This will clear ALL browser data for Bambusy including your sidebar order, preferences, and cached data. The page will reload after clearing. This action cannot be undone!"
confirmText="Clear Everything"
variant="danger"
onConfirm={() => {
setShowClearStorageConfirm(false);
localStorage.clear();
showToast('Local storage cleared. Refreshing...', 'success');
setTimeout(() => window.location.reload(), 1000);
}}
onCancel={() => setShowClearStorageConfirm(false)}
/>
)}
{/* Confirm Modal: Bulk Plug Action */}
{showBulkPlugConfirm && (
<ConfirmModal
title={`Turn All Plugs ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
message={`This will turn ${showBulkPlugConfirm === 'on' ? 'ON' : 'OFF'} all ${smartPlugs?.filter(p => p.enabled).length || 0} enabled smart plugs. ${showBulkPlugConfirm === 'off' ? 'Any running printers may be affected!' : ''}`}
confirmText={`Turn All ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
variant={showBulkPlugConfirm === 'off' ? 'danger' : 'warning'}
onConfirm={() => {
const action = showBulkPlugConfirm;
setShowBulkPlugConfirm(null);
bulkPlugActionMutation.mutate(action);
}}
onCancel={() => setShowBulkPlugConfirm(null)}
/>
)}
</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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-C99F9s0L.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BomzirkH.css">
<script type="module" crossorigin src="/assets/index-D9lKq6O-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Crbfjp9b.css">
</head>
<body>
<div id="root"></div>