Browse Source

- Added smart plug monitoring and scheduling
- Added daily digest to notification module

maziggy 5 months ago
parent
commit
c74f24353a

+ 211 - 17
backend/app/api/routes/notifications.py

@@ -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
backend/app/api/routes/settings.py

@@ -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
backend/app/api/routes/smart_plugs.py

@@ -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 - 0
backend/app/core/database.py

@@ -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 - 0
backend/app/main.py

@@ -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 - 0
backend/app/models/__init__.py

@@ -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
backend/app/models/notification.py

@@ -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
backend/app/models/smart_plug.py

@@ -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
backend/app/schemas/notification.py

@@ -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 - 0
backend/app/schemas/settings.py

@@ -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 - 0
backend/app/schemas/smart_plug.py

@@ -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
backend/app/services/notification_service.py

@@ -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()

+ 70 - 0
backend/app/services/smart_plug_manager.py

@@ -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
frontend/src/api/client.ts

@@ -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: () =>

+ 51 - 3
frontend/src/components/AddNotificationModal.tsx

@@ -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
frontend/src/components/AddSmartPlugModal.tsx

@@ -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

+ 287 - 0
frontend/src/components/NotificationLogViewer.tsx

@@ -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>
+  );
+}

+ 36 - 1
frontend/src/components/NotificationProviderCard.tsx

@@ -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
frontend/src/components/SmartPlugCard.tsx

@@ -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
frontend/src/pages/SettingsPage.tsx

@@ -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 it is too large
+ 0 - 0
static/assets/index-BomzirkH.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-C99F9s0L.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Crbfjp9b.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D9lKq6O-.js


+ 2 - 2
static/index.html

@@ -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>

Some files were not shown because too many files changed in this diff