mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 00:38:38 +02:00
- Added smart plug monitoring and scheduling
- Added daily digest to notification module
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Notification service for sending push notifications via various providers."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -14,7 +15,7 @@ import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.notification import NotificationProvider
|
||||
from backend.app.models.notification import NotificationLog, NotificationProvider, NotificationDigestQueue
|
||||
from backend.app.models.notification_template import NotificationTemplate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,6 +27,8 @@ class NotificationService:
|
||||
def __init__(self):
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
self._template_cache: dict[str, NotificationTemplate] = {}
|
||||
self._digest_scheduler_task: asyncio.Task | None = None
|
||||
self._last_digest_check: str = "" # "HH:MM" to avoid duplicate checks
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client."""
|
||||
@@ -150,6 +153,10 @@ class NotificationService:
|
||||
return await self._send_telegram(config, f"*{title}*\n{message}")
|
||||
elif provider_type == "email":
|
||||
return await self._send_email(config, title, message)
|
||||
elif provider_type == "discord":
|
||||
return await self._send_discord(config, title, message)
|
||||
elif provider_type == "webhook":
|
||||
return await self._send_webhook(config, title, message)
|
||||
else:
|
||||
return False, f"Unknown provider type: {provider_type}"
|
||||
except Exception as e:
|
||||
@@ -308,6 +315,70 @@ class NotificationService:
|
||||
except Exception as e:
|
||||
return False, f"Email error: {str(e)}"
|
||||
|
||||
async def _send_discord(self, config: dict, title: str, message: str) -> tuple[bool, str]:
|
||||
"""Send notification via Discord webhook."""
|
||||
webhook_url = config.get("webhook_url", "").strip()
|
||||
|
||||
if not webhook_url:
|
||||
return False, "Webhook URL is required"
|
||||
|
||||
if not webhook_url.startswith("https://discord.com/api/webhooks/"):
|
||||
return False, "Invalid Discord webhook URL"
|
||||
|
||||
# Discord embed format for nicer messages
|
||||
data = {
|
||||
"embeds": [{
|
||||
"title": title,
|
||||
"description": message,
|
||||
"color": 0x00AE42, # Bambu green
|
||||
}]
|
||||
}
|
||||
|
||||
client = await self._get_client()
|
||||
response = await client.post(webhook_url, json=data)
|
||||
|
||||
if response.status_code in (200, 204):
|
||||
return True, "Message sent successfully"
|
||||
else:
|
||||
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
||||
|
||||
async def _send_webhook(self, config: dict, title: str, message: str) -> tuple[bool, str]:
|
||||
"""Send notification via generic webhook (POST JSON)."""
|
||||
webhook_url = config.get("webhook_url", "").strip()
|
||||
auth_header = config.get("auth_header", "").strip()
|
||||
custom_field_title = config.get("field_title", "title").strip() or "title"
|
||||
custom_field_message = config.get("field_message", "message").strip() or "message"
|
||||
|
||||
if not webhook_url:
|
||||
return False, "Webhook URL is required"
|
||||
|
||||
# Build payload with custom field names
|
||||
data = {
|
||||
custom_field_title: title,
|
||||
custom_field_message: message,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"source": "BambuTrack",
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if auth_header:
|
||||
# Support "Bearer token" or just "token" format
|
||||
if " " in auth_header:
|
||||
headers["Authorization"] = auth_header
|
||||
else:
|
||||
headers["Authorization"] = f"Bearer {auth_header}"
|
||||
|
||||
client = await self._get_client()
|
||||
try:
|
||||
response = await client.post(webhook_url, json=data, headers=headers)
|
||||
|
||||
if response.status_code in (200, 201, 202, 204):
|
||||
return True, "Webhook delivered successfully"
|
||||
else:
|
||||
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
||||
except Exception as e:
|
||||
return False, f"Webhook error: {str(e)}"
|
||||
|
||||
async def _send_to_provider(
|
||||
self, provider: NotificationProvider, title: str, message: str
|
||||
) -> tuple[bool, str]:
|
||||
@@ -330,6 +401,10 @@ class NotificationService:
|
||||
return await self._send_telegram(config, f"*{title}*\n{message}")
|
||||
elif provider.provider_type == "email":
|
||||
return await self._send_email(config, title, message)
|
||||
elif provider.provider_type == "discord":
|
||||
return await self._send_discord(config, title, message)
|
||||
elif provider.provider_type == "webhook":
|
||||
return await self._send_webhook(config, title, message)
|
||||
else:
|
||||
return False, f"Unknown provider type: {provider.provider_type}"
|
||||
except Exception as e:
|
||||
@@ -373,18 +448,75 @@ class NotificationService:
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def _log_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
provider_id: int,
|
||||
event_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
success: bool,
|
||||
error_message: str | None = None,
|
||||
printer_id: int | None = None,
|
||||
printer_name: str | None = None,
|
||||
):
|
||||
"""Create a log entry for a sent notification."""
|
||||
try:
|
||||
log = NotificationLog(
|
||||
provider_id=provider_id,
|
||||
event_type=event_type,
|
||||
title=title,
|
||||
message=message,
|
||||
success=success,
|
||||
error_message=error_message,
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
)
|
||||
db.add(log)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to log notification: {e}")
|
||||
# Don't fail the notification just because logging failed
|
||||
|
||||
async def _send_to_providers(
|
||||
self,
|
||||
providers: list[NotificationProvider],
|
||||
title: str,
|
||||
message: str,
|
||||
db: AsyncSession,
|
||||
event_type: str = "unknown",
|
||||
printer_id: int | None = None,
|
||||
printer_name: str | None = None,
|
||||
):
|
||||
"""Send notification to multiple providers."""
|
||||
"""Send notification to multiple providers and log the results."""
|
||||
for provider in providers:
|
||||
try:
|
||||
# Check if provider wants digest mode
|
||||
if provider.daily_digest_enabled and provider.daily_digest_time:
|
||||
await self._queue_for_digest(
|
||||
provider=provider,
|
||||
event_type=event_type,
|
||||
title=title,
|
||||
message=message,
|
||||
db=db,
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
)
|
||||
continue
|
||||
|
||||
success, error = await self._send_to_provider(provider, title, message)
|
||||
await self._update_provider_status(db, provider.id, success, error if not success else None)
|
||||
await self._log_notification(
|
||||
db=db,
|
||||
provider_id=provider.id,
|
||||
event_type=event_type,
|
||||
title=title,
|
||||
message=message,
|
||||
success=success,
|
||||
error_message=error if not success else None,
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Sent notification via {provider.name}")
|
||||
else:
|
||||
@@ -392,6 +524,17 @@ class NotificationService:
|
||||
except Exception as e:
|
||||
logger.exception(f"Error sending notification via {provider.name}")
|
||||
await self._update_provider_status(db, provider.id, False, str(e))
|
||||
await self._log_notification(
|
||||
db=db,
|
||||
provider_id=provider.id,
|
||||
event_type=event_type,
|
||||
title=title,
|
||||
message=message,
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
)
|
||||
|
||||
async def on_print_start(
|
||||
self, printer_id: int, printer_name: str, data: dict, db: AsyncSession
|
||||
@@ -415,7 +558,7 @@ class NotificationService:
|
||||
|
||||
logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
|
||||
title, message = await self._build_message_from_template(db, "print_start", variables)
|
||||
await self._send_to_providers(providers, title, message, db)
|
||||
await self._send_to_providers(providers, title, message, db, "print_start", printer_id, printer_name)
|
||||
|
||||
async def on_print_complete(
|
||||
self,
|
||||
@@ -469,7 +612,7 @@ class NotificationService:
|
||||
|
||||
logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
|
||||
title, message = await self._build_message_from_template(db, event_type, variables)
|
||||
await self._send_to_providers(providers, title, message, db)
|
||||
await self._send_to_providers(providers, title, message, db, event_type, printer_id, printer_name)
|
||||
|
||||
async def on_print_progress(
|
||||
self,
|
||||
@@ -493,7 +636,7 @@ class NotificationService:
|
||||
}
|
||||
|
||||
title, message = await self._build_message_from_template(db, "print_progress", variables)
|
||||
await self._send_to_providers(providers, title, message, db)
|
||||
await self._send_to_providers(providers, title, message, db, "print_progress", printer_id, printer_name)
|
||||
|
||||
async def on_printer_offline(
|
||||
self, printer_id: int, printer_name: str, db: AsyncSession
|
||||
@@ -506,7 +649,7 @@ class NotificationService:
|
||||
variables = {"printer": printer_name}
|
||||
|
||||
title, message = await self._build_message_from_template(db, "printer_offline", variables)
|
||||
await self._send_to_providers(providers, title, message, db)
|
||||
await self._send_to_providers(providers, title, message, db, "printer_offline", printer_id, printer_name)
|
||||
|
||||
async def on_printer_error(
|
||||
self,
|
||||
@@ -528,7 +671,7 @@ class NotificationService:
|
||||
}
|
||||
|
||||
title, message = await self._build_message_from_template(db, "printer_error", variables)
|
||||
await self._send_to_providers(providers, title, message, db)
|
||||
await self._send_to_providers(providers, title, message, db, "printer_error", printer_id, printer_name)
|
||||
|
||||
async def on_filament_low(
|
||||
self,
|
||||
@@ -552,7 +695,7 @@ class NotificationService:
|
||||
}
|
||||
|
||||
title, message = await self._build_message_from_template(db, "filament_low", variables)
|
||||
await self._send_to_providers(providers, title, message, db)
|
||||
await self._send_to_providers(providers, title, message, db, "filament_low", printer_id, printer_name)
|
||||
|
||||
async def on_maintenance_due(
|
||||
self,
|
||||
@@ -584,12 +727,163 @@ class NotificationService:
|
||||
|
||||
logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}")
|
||||
title, message = await self._build_message_from_template(db, "maintenance_due", variables)
|
||||
await self._send_to_providers(providers, title, message, db)
|
||||
await self._send_to_providers(providers, title, message, db, "maintenance_due", printer_id, printer_name)
|
||||
|
||||
def clear_template_cache(self):
|
||||
"""Clear the template cache. Call this when templates are updated."""
|
||||
self._template_cache.clear()
|
||||
|
||||
async def _queue_for_digest(
|
||||
self,
|
||||
provider: NotificationProvider,
|
||||
event_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
db: AsyncSession,
|
||||
printer_id: int | None = None,
|
||||
printer_name: str | None = None,
|
||||
):
|
||||
"""Queue a notification for later delivery in the daily digest."""
|
||||
try:
|
||||
queue_entry = NotificationDigestQueue(
|
||||
provider_id=provider.id,
|
||||
event_type=event_type,
|
||||
title=title,
|
||||
message=message,
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
)
|
||||
db.add(queue_entry)
|
||||
await db.commit()
|
||||
logger.info(f"Queued notification for digest: {event_type} for provider {provider.name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to queue notification for digest: {e}")
|
||||
|
||||
async def send_digest(self, provider_id: int):
|
||||
"""Send all queued notifications as a single digest for a provider."""
|
||||
from backend.app.core.database import async_session
|
||||
|
||||
async with async_session() as db:
|
||||
# Get the provider
|
||||
result = await db.execute(
|
||||
select(NotificationProvider).where(NotificationProvider.id == provider_id)
|
||||
)
|
||||
provider = result.scalar_one_or_none()
|
||||
|
||||
if not provider or not provider.enabled:
|
||||
return
|
||||
|
||||
# Get all queued notifications for this provider
|
||||
result = await db.execute(
|
||||
select(NotificationDigestQueue)
|
||||
.where(NotificationDigestQueue.provider_id == provider_id)
|
||||
.order_by(NotificationDigestQueue.created_at)
|
||||
)
|
||||
queue_entries = list(result.scalars().all())
|
||||
|
||||
if not queue_entries:
|
||||
logger.debug(f"No queued notifications for provider {provider.name}")
|
||||
return
|
||||
|
||||
# Build digest message
|
||||
title = f"Daily Digest - {len(queue_entries)} Events"
|
||||
|
||||
# Group by event type
|
||||
events_by_type: dict[str, list] = {}
|
||||
for entry in queue_entries:
|
||||
if entry.event_type not in events_by_type:
|
||||
events_by_type[entry.event_type] = []
|
||||
events_by_type[entry.event_type].append(entry)
|
||||
|
||||
# Format the digest body
|
||||
body_parts = []
|
||||
for event_type, entries in events_by_type.items():
|
||||
event_label = event_type.replace("_", " ").title()
|
||||
body_parts.append(f"== {event_label} ({len(entries)}) ==")
|
||||
for entry in entries:
|
||||
time_str = entry.created_at.strftime("%H:%M")
|
||||
printer_info = f"[{entry.printer_name}] " if entry.printer_name else ""
|
||||
body_parts.append(f" {time_str} {printer_info}{entry.title}")
|
||||
body_parts.append("")
|
||||
|
||||
body = "\n".join(body_parts)
|
||||
|
||||
# Send the digest
|
||||
success, error = await self._send_to_provider(provider, title, body)
|
||||
|
||||
# Log the digest
|
||||
await self._log_notification(
|
||||
db=db,
|
||||
provider_id=provider.id,
|
||||
event_type="daily_digest",
|
||||
title=title,
|
||||
message=body,
|
||||
success=success,
|
||||
error_message=error if not success else None,
|
||||
)
|
||||
|
||||
# Clear the queue
|
||||
for entry in queue_entries:
|
||||
await db.delete(entry)
|
||||
await db.commit()
|
||||
|
||||
if success:
|
||||
logger.info(f"Sent daily digest with {len(queue_entries)} events to {provider.name}")
|
||||
else:
|
||||
logger.warning(f"Failed to send daily digest to {provider.name}: {error}")
|
||||
|
||||
async def check_and_send_digests(self):
|
||||
"""Check all providers and send digests if it's their scheduled time."""
|
||||
from backend.app.core.database import async_session
|
||||
|
||||
current_time = datetime.now().strftime("%H:%M")
|
||||
|
||||
# Avoid duplicate checks within the same minute
|
||||
if current_time == self._last_digest_check:
|
||||
return
|
||||
self._last_digest_check = current_time
|
||||
|
||||
async with async_session() as db:
|
||||
# Find all providers with digest enabled at this time
|
||||
result = await db.execute(
|
||||
select(NotificationProvider).where(
|
||||
NotificationProvider.enabled == True,
|
||||
NotificationProvider.daily_digest_enabled == True,
|
||||
NotificationProvider.daily_digest_time == current_time,
|
||||
)
|
||||
)
|
||||
providers = result.scalars().all()
|
||||
|
||||
for provider in providers:
|
||||
try:
|
||||
await self.send_digest(provider.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending digest for provider {provider.id}: {e}")
|
||||
|
||||
def start_digest_scheduler(self):
|
||||
"""Start the background scheduler for daily digest notifications."""
|
||||
if self._digest_scheduler_task is None:
|
||||
self._digest_scheduler_task = asyncio.create_task(self._digest_scheduler_loop())
|
||||
logger.info("Notification digest scheduler started")
|
||||
|
||||
def stop_digest_scheduler(self):
|
||||
"""Stop the background scheduler for daily digests."""
|
||||
if self._digest_scheduler_task:
|
||||
self._digest_scheduler_task.cancel()
|
||||
self._digest_scheduler_task = None
|
||||
logger.info("Notification digest scheduler stopped")
|
||||
|
||||
async def _digest_scheduler_loop(self):
|
||||
"""Background loop that checks for scheduled digests every minute."""
|
||||
while True:
|
||||
try:
|
||||
await self.check_and_send_digests()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in digest scheduler: {e}")
|
||||
|
||||
# Wait until the next minute
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
# Global instance
|
||||
notification_service = NotificationService()
|
||||
|
||||
@@ -23,11 +23,81 @@ class SmartPlugManager:
|
||||
def __init__(self):
|
||||
self._pending_off: dict[int, asyncio.Task] = {} # plug_id -> task
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._scheduler_task: asyncio.Task | None = None
|
||||
self._last_schedule_check: dict[int, str] = {} # plug_id -> "HH:MM" last executed
|
||||
|
||||
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
|
||||
"""Set the event loop for async operations."""
|
||||
self._loop = loop
|
||||
|
||||
def start_scheduler(self):
|
||||
"""Start the background scheduler for time-based plug control."""
|
||||
if self._scheduler_task is None:
|
||||
self._scheduler_task = asyncio.create_task(self._schedule_loop())
|
||||
logger.info("Smart plug scheduler started")
|
||||
|
||||
def stop_scheduler(self):
|
||||
"""Stop the background scheduler."""
|
||||
if self._scheduler_task:
|
||||
self._scheduler_task.cancel()
|
||||
self._scheduler_task = None
|
||||
logger.info("Smart plug scheduler stopped")
|
||||
|
||||
async def _schedule_loop(self):
|
||||
"""Background loop that checks scheduled on/off times every minute."""
|
||||
while True:
|
||||
try:
|
||||
await self._check_schedules()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in schedule check: {e}")
|
||||
|
||||
# Wait until the next minute
|
||||
await asyncio.sleep(60)
|
||||
|
||||
async def _check_schedules(self):
|
||||
"""Check all plugs for scheduled on/off times."""
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
|
||||
current_time = datetime.now().strftime("%H:%M")
|
||||
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(SmartPlug).where(
|
||||
SmartPlug.enabled == True,
|
||||
SmartPlug.schedule_enabled == True,
|
||||
)
|
||||
)
|
||||
plugs = result.scalars().all()
|
||||
|
||||
for plug in plugs:
|
||||
# Check if we should turn on
|
||||
if plug.schedule_on_time == current_time:
|
||||
last_check = self._last_schedule_check.get(plug.id)
|
||||
if last_check != f"on:{current_time}":
|
||||
logger.info(f"Schedule: Turning on plug '{plug.name}' at {current_time}")
|
||||
success = await tasmota_service.turn_on(plug)
|
||||
if success:
|
||||
plug.last_state = "ON"
|
||||
plug.last_checked = datetime.utcnow()
|
||||
self._last_schedule_check[plug.id] = f"on:{current_time}"
|
||||
|
||||
# Check if we should turn off
|
||||
if plug.schedule_off_time == current_time:
|
||||
last_check = self._last_schedule_check.get(plug.id)
|
||||
if last_check != f"off:{current_time}":
|
||||
logger.info(f"Schedule: Turning off plug '{plug.name}' at {current_time}")
|
||||
success = await tasmota_service.turn_off(plug)
|
||||
if success:
|
||||
plug.last_state = "OFF"
|
||||
plug.last_checked = datetime.utcnow()
|
||||
self._last_schedule_check[plug.id] = f"off:{current_time}"
|
||||
# Mark printer offline if linked
|
||||
if plug.printer_id:
|
||||
printer_manager.mark_printer_offline(plug.printer_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
async def _get_plug_for_printer(
|
||||
self, printer_id: int, db: AsyncSession
|
||||
) -> "SmartPlug | None":
|
||||
|
||||
+121
-1
@@ -252,6 +252,11 @@ export interface AppSettings {
|
||||
ams_humidity_fair: number; // <= this is orange, > is red
|
||||
ams_temp_good: number; // <= this is green/blue
|
||||
ams_temp_fair: number; // <= this is orange, > is red
|
||||
// Date/time format settings
|
||||
date_format: 'system' | 'us' | 'eu' | 'iso';
|
||||
time_format: 'system' | '12h' | '24h';
|
||||
// Default printer
|
||||
default_printer_id: number | null;
|
||||
}
|
||||
|
||||
export type AppSettingsUpdate = Partial<AppSettings>;
|
||||
@@ -362,6 +367,16 @@ export interface SmartPlug {
|
||||
off_temp_threshold: number;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
// Power alerts
|
||||
power_alert_enabled: boolean;
|
||||
power_alert_high: number | null;
|
||||
power_alert_low: number | null;
|
||||
power_alert_last_triggered: string | null;
|
||||
// Schedule
|
||||
schedule_enabled: boolean;
|
||||
schedule_on_time: string | null;
|
||||
schedule_off_time: string | null;
|
||||
// Status
|
||||
last_state: string | null;
|
||||
last_checked: string | null;
|
||||
auto_off_executed: boolean; // True when auto-off was triggered after print
|
||||
@@ -381,6 +396,14 @@ export interface SmartPlugCreate {
|
||||
off_temp_threshold?: number;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
// Power alerts
|
||||
power_alert_enabled?: boolean;
|
||||
power_alert_high?: number | null;
|
||||
power_alert_low?: number | null;
|
||||
// Schedule
|
||||
schedule_enabled?: boolean;
|
||||
schedule_on_time?: string | null;
|
||||
schedule_off_time?: string | null;
|
||||
}
|
||||
|
||||
export interface SmartPlugUpdate {
|
||||
@@ -395,6 +418,14 @@ export interface SmartPlugUpdate {
|
||||
off_temp_threshold?: number;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
// Power alerts
|
||||
power_alert_enabled?: boolean;
|
||||
power_alert_high?: number | null;
|
||||
power_alert_low?: number | null;
|
||||
// Schedule
|
||||
schedule_enabled?: boolean;
|
||||
schedule_on_time?: string | null;
|
||||
schedule_off_time?: string | null;
|
||||
}
|
||||
|
||||
export interface SmartPlugEnergy {
|
||||
@@ -552,7 +583,7 @@ export interface Filament {
|
||||
}
|
||||
|
||||
// Notification Provider types
|
||||
export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email';
|
||||
export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook';
|
||||
|
||||
export interface NotificationProvider {
|
||||
id: number;
|
||||
@@ -575,6 +606,9 @@ export interface NotificationProvider {
|
||||
quiet_hours_enabled: boolean;
|
||||
quiet_hours_start: string | null;
|
||||
quiet_hours_end: string | null;
|
||||
// Daily digest
|
||||
daily_digest_enabled: boolean;
|
||||
daily_digest_time: string | null;
|
||||
// Printer filter
|
||||
printer_id: number | null;
|
||||
// Status tracking
|
||||
@@ -606,6 +640,9 @@ export interface NotificationProviderCreate {
|
||||
quiet_hours_enabled?: boolean;
|
||||
quiet_hours_start?: string | null;
|
||||
quiet_hours_end?: string | null;
|
||||
// Daily digest
|
||||
daily_digest_enabled?: boolean;
|
||||
daily_digest_time?: string | null;
|
||||
// Printer filter
|
||||
printer_id?: number | null;
|
||||
}
|
||||
@@ -630,6 +667,9 @@ export interface NotificationProviderUpdate {
|
||||
quiet_hours_enabled?: boolean;
|
||||
quiet_hours_start?: string | null;
|
||||
quiet_hours_end?: string | null;
|
||||
// Daily digest
|
||||
daily_digest_enabled?: boolean;
|
||||
daily_digest_time?: string | null;
|
||||
// Printer filter
|
||||
printer_id?: number | null;
|
||||
}
|
||||
@@ -711,6 +751,30 @@ export interface TemplatePreviewResponse {
|
||||
body: string;
|
||||
}
|
||||
|
||||
// Notification Log types
|
||||
export interface NotificationLogEntry {
|
||||
id: number;
|
||||
provider_id: number;
|
||||
provider_name: string | null;
|
||||
provider_type: string | null;
|
||||
event_type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
success: boolean;
|
||||
error_message: string | null;
|
||||
printer_id: number | null;
|
||||
printer_name: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NotificationLogStats {
|
||||
total: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
by_event_type: Record<string, number>;
|
||||
by_provider: Record<string, number>;
|
||||
}
|
||||
|
||||
// Spoolman types
|
||||
export interface SpoolmanStatus {
|
||||
enabled: boolean;
|
||||
@@ -1085,6 +1149,23 @@ export const api = {
|
||||
}),
|
||||
resetSettings: () =>
|
||||
request<AppSettings>('/settings/reset', { method: 'POST' }),
|
||||
exportBackup: async () => {
|
||||
const response = await fetch(`${API_BASE}/settings/backup`);
|
||||
return response.json();
|
||||
},
|
||||
importBackup: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await fetch(`${API_BASE}/settings/restore`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
return response.json() as Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
restored?: { settings: number; notification_providers: number; smart_plugs: number };
|
||||
}>;
|
||||
},
|
||||
checkFfmpeg: () =>
|
||||
request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
|
||||
|
||||
@@ -1263,6 +1344,19 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
testAllNotificationProviders: () =>
|
||||
request<{
|
||||
tested: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
results: Array<{
|
||||
provider_id: number;
|
||||
provider_name: string;
|
||||
provider_type: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>;
|
||||
}>('/notifications/test-all', { method: 'POST' }),
|
||||
|
||||
// Notification Templates
|
||||
getNotificationTemplates: () => request<NotificationTemplate[]>('/notification-templates'),
|
||||
@@ -1283,6 +1377,32 @@ export const api = {
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Notification Logs
|
||||
getNotificationLogs: (params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
provider_id?: number;
|
||||
event_type?: string;
|
||||
success?: boolean;
|
||||
days?: number;
|
||||
}) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||||
if (params?.offset) searchParams.set('offset', String(params.offset));
|
||||
if (params?.provider_id) searchParams.set('provider_id', String(params.provider_id));
|
||||
if (params?.event_type) searchParams.set('event_type', params.event_type);
|
||||
if (params?.success !== undefined) searchParams.set('success', String(params.success));
|
||||
if (params?.days) searchParams.set('days', String(params.days));
|
||||
return request<NotificationLogEntry[]>(`/notifications/logs?${searchParams}`);
|
||||
},
|
||||
getNotificationLogStats: (days = 7) =>
|
||||
request<NotificationLogStats>(`/notifications/logs/stats?days=${days}`),
|
||||
clearNotificationLogs: (olderThanDays = 30) =>
|
||||
request<{ deleted: number; message: string }>(
|
||||
`/notifications/logs?older_than_days=${olderThanDays}`,
|
||||
{ method: 'DELETE' }
|
||||
),
|
||||
|
||||
// Spoolman Integration
|
||||
getSpoolmanStatus: () => request<SpoolmanStatus>('/spoolman/status'),
|
||||
connectSpoolman: () =>
|
||||
|
||||
@@ -12,11 +12,13 @@ interface AddNotificationModalProps {
|
||||
}
|
||||
|
||||
const PROVIDER_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
|
||||
{ value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
|
||||
{ value: 'discord', label: 'Discord', description: 'Send to Discord channel via webhook' },
|
||||
{ value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
|
||||
{ value: 'ntfy', label: 'ntfy', description: 'Free, self-hostable push notifications' },
|
||||
{ value: 'pushover', label: 'Pushover', description: 'Simple, reliable push notifications' },
|
||||
{ value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
|
||||
{ value: 'email', label: 'Email', description: 'SMTP email notifications' },
|
||||
{ value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
|
||||
{ value: 'webhook', label: 'Webhook', description: 'Generic HTTP POST to any URL' },
|
||||
];
|
||||
|
||||
export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
|
||||
@@ -24,12 +26,16 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
const isEditing = !!provider;
|
||||
|
||||
const [name, setName] = useState(provider?.name || '');
|
||||
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'ntfy');
|
||||
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'discord');
|
||||
const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
|
||||
const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
|
||||
const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
|
||||
const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
|
||||
|
||||
// Daily digest
|
||||
const [dailyDigestEnabled, setDailyDigestEnabled] = useState(provider?.daily_digest_enabled || false);
|
||||
const [dailyDigestTime, setDailyDigestTime] = useState(provider?.daily_digest_time || '08:00');
|
||||
|
||||
// Event toggles
|
||||
const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false);
|
||||
const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true);
|
||||
@@ -126,6 +132,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
quiet_hours_enabled: quietHoursEnabled,
|
||||
quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
|
||||
quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
|
||||
// Daily digest
|
||||
daily_digest_enabled: dailyDigestEnabled,
|
||||
daily_digest_time: dailyDigestEnabled ? dailyDigestTime : null,
|
||||
// Event toggles
|
||||
on_print_start: onPrintStart,
|
||||
on_print_complete: onPrintComplete,
|
||||
@@ -190,6 +199,17 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
{ key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true },
|
||||
{ key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true },
|
||||
];
|
||||
case 'discord':
|
||||
return [
|
||||
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'text', required: true },
|
||||
];
|
||||
case 'webhook':
|
||||
return [
|
||||
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'text', required: true },
|
||||
{ key: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false },
|
||||
{ key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false },
|
||||
{ key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -401,6 +421,34 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Digest */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm text-white">Daily Digest</label>
|
||||
<p className="text-xs text-bambu-gray">Batch notifications into a single daily summary</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={dailyDigestEnabled}
|
||||
onChange={setDailyDigestEnabled}
|
||||
/>
|
||||
</div>
|
||||
{dailyDigestEnabled && (
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">Send digest at</label>
|
||||
<input
|
||||
type="time"
|
||||
value={dailyDigestTime}
|
||||
onChange={(e) => setDailyDigestTime(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Events will be collected and sent as a single summary at this time
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Toggles */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-bambu-gray">Notification Events</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle } from 'lucide-react';
|
||||
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
@@ -22,6 +22,16 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Power alert settings
|
||||
const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
|
||||
const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
|
||||
const [powerAlertLow, setPowerAlertLow] = useState<string>(plug?.power_alert_low?.toString() || '');
|
||||
|
||||
// Schedule settings
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(plug?.schedule_enabled || false);
|
||||
const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
|
||||
const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
|
||||
|
||||
// Fetch printers for linking
|
||||
const { data: printers } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
@@ -109,6 +119,14 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
username: username.trim() || null,
|
||||
password: password.trim() || null,
|
||||
printer_id: printerId,
|
||||
// Power alerts
|
||||
power_alert_enabled: powerAlertEnabled,
|
||||
power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
|
||||
power_alert_low: powerAlertLow ? parseFloat(powerAlertLow) : null,
|
||||
// Schedule
|
||||
schedule_enabled: scheduleEnabled,
|
||||
schedule_on_time: scheduleOnTime || null,
|
||||
schedule_off_time: scheduleOffTime || null,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
@@ -266,6 +284,104 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Power Alerts */}
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="w-4 h-4 text-bambu-green" />
|
||||
<span className="text-white font-medium">Power Alerts</span>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={powerAlertEnabled}
|
||||
onChange={(e) => setPowerAlertEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
{powerAlertEnabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Alert if above (W)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={powerAlertHigh}
|
||||
onChange={(e) => setPowerAlertHigh(e.target.value)}
|
||||
placeholder="e.g. 200"
|
||||
min="0"
|
||||
max="5000"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Alert if below (W)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={powerAlertLow}
|
||||
onChange={(e) => setPowerAlertLow(e.target.value)}
|
||||
placeholder="e.g. 10"
|
||||
min="0"
|
||||
max="5000"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schedule */}
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-bambu-green" />
|
||||
<span className="text-white font-medium">Daily Schedule</span>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scheduleEnabled}
|
||||
onChange={(e) => setScheduleEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
{scheduleEnabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleOnTime}
|
||||
onChange={(e) => setScheduleOnTime(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
|
||||
<input
|
||||
type="time"
|
||||
value={scheduleOffTime}
|
||||
onChange={(e) => setScheduleOffTime(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { History, CheckCircle, XCircle, Loader2, Trash2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { NotificationLogEntry } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
print_start: 'Print Started',
|
||||
print_complete: 'Print Complete',
|
||||
print_failed: 'Print Failed',
|
||||
print_stopped: 'Print Stopped',
|
||||
print_progress: 'Progress',
|
||||
printer_offline: 'Printer Offline',
|
||||
printer_error: 'Printer Error',
|
||||
filament_low: 'Low Filament',
|
||||
maintenance_due: 'Maintenance Due',
|
||||
test: 'Test',
|
||||
};
|
||||
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
print_start: 'text-blue-400',
|
||||
print_complete: 'text-bambu-green',
|
||||
print_failed: 'text-red-400',
|
||||
print_stopped: 'text-orange-400',
|
||||
print_progress: 'text-yellow-400',
|
||||
printer_offline: 'text-gray-400',
|
||||
printer_error: 'text-rose-400',
|
||||
filament_low: 'text-cyan-400',
|
||||
maintenance_due: 'text-purple-400',
|
||||
test: 'text-bambu-gray',
|
||||
};
|
||||
|
||||
interface NotificationLogViewerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [days, setDays] = useState(7);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [showFailedOnly, setShowFailedOnly] = useState(false);
|
||||
|
||||
const { data: logs, isLoading, refetch, isRefetching } = useQuery({
|
||||
queryKey: ['notification-logs', days, showFailedOnly],
|
||||
queryFn: () => api.getNotificationLogs({
|
||||
days,
|
||||
limit: 100,
|
||||
success: showFailedOnly ? false : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['notification-log-stats', days],
|
||||
queryFn: () => api.getNotificationLogStats(days),
|
||||
});
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => api.clearNotificationLogs(30),
|
||||
onSuccess: (data) => {
|
||||
showToast(data.message, 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-logs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-log-stats'] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(`Failed to clear logs: ${error.message}`, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
if (diff < 60000) return 'Just now';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg w-full max-w-3xl max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<History className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Notification Log</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-bambu-gray hover:text-white transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{stats && (
|
||||
<div className="px-4 py-3 border-b border-bambu-dark-tertiary bg-bambu-dark/50">
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<span className="text-bambu-gray">
|
||||
Last {days} days: <span className="text-white font-medium">{stats.total}</span> notifications
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-bambu-green">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{stats.success_count} sent
|
||||
</span>
|
||||
{stats.failure_count > 0 && (
|
||||
<span className="flex items-center gap-1 text-red-400">
|
||||
<XCircle className="w-4 h-4" />
|
||||
{stats.failure_count} failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="px-4 py-3 border-b border-bambu-dark-tertiary flex items-center gap-4">
|
||||
<select
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-bambu-green"
|
||||
>
|
||||
<option value={1}>Last 24 hours</option>
|
||||
<option value={7}>Last 7 days</option>
|
||||
<option value={30}>Last 30 days</option>
|
||||
<option value={90}>Last 90 days</option>
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showFailedOnly}
|
||||
onChange={(e) => setShowFailedOnly(e.target.checked)}
|
||||
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
|
||||
/>
|
||||
Show failed only
|
||||
</label>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
>
|
||||
{isRefetching ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => clearMutation.mutate()}
|
||||
disabled={clearMutation.isPending}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
{clearMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
Clear Old
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Log List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
) : logs && logs.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => (
|
||||
<LogEntry
|
||||
key={log.id}
|
||||
log={log}
|
||||
isExpanded={expandedId === log.id}
|
||||
onToggle={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-bambu-gray">
|
||||
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">
|
||||
{showFailedOnly ? 'No failed notifications' : 'No notifications logged'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogEntry({
|
||||
log,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
formatDate,
|
||||
}: {
|
||||
log: NotificationLogEntry;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
formatDate: (date: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-lg overflow-hidden transition-colors ${
|
||||
log.success
|
||||
? 'border-bambu-dark-tertiary bg-bambu-dark/30'
|
||||
: 'border-red-500/30 bg-red-500/5'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-bambu-dark/50 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{log.success ? (
|
||||
<CheckCircle className="w-4 h-4 text-bambu-green shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-400 shrink-0" />
|
||||
)}
|
||||
|
||||
<span className={`text-xs font-medium ${EVENT_COLORS[log.event_type] || 'text-bambu-gray'}`}>
|
||||
{EVENT_LABELS[log.event_type] || log.event_type}
|
||||
</span>
|
||||
|
||||
<span className="text-sm text-white truncate flex-1">
|
||||
{log.provider_name || 'Unknown Provider'}
|
||||
</span>
|
||||
|
||||
{log.printer_name && (
|
||||
<span className="text-xs text-bambu-gray">
|
||||
{log.printer_name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-bambu-gray shrink-0">
|
||||
{formatDate(log.created_at)}
|
||||
</span>
|
||||
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-bambu-gray shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-bambu-gray shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 border-t border-bambu-dark-tertiary bg-bambu-dark/20 space-y-2">
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray mb-1">Title</p>
|
||||
<p className="text-sm text-white">{log.title}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray mb-1">Message</p>
|
||||
<p className="text-sm text-white whitespace-pre-wrap">{log.message}</p>
|
||||
</div>
|
||||
{!log.success && log.error_message && (
|
||||
<div>
|
||||
<p className="text-xs text-red-400 mb-1">Error</p>
|
||||
<p className="text-sm text-red-300">{log.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4 text-xs text-bambu-gray pt-1">
|
||||
<span>Provider: {log.provider_type}</span>
|
||||
<span>Time: {new Date(log.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp, Calendar } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { NotificationProvider, NotificationProviderUpdate } from '../api/client';
|
||||
import { Card, CardContent } from './Card';
|
||||
@@ -19,6 +19,8 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||
pushover: 'Pushover',
|
||||
telegram: 'Telegram',
|
||||
email: 'Email',
|
||||
discord: 'Discord',
|
||||
webhook: 'Webhook',
|
||||
};
|
||||
|
||||
export function NotificationProviderCard({ provider, onEdit }: NotificationProviderCardProps) {
|
||||
@@ -147,6 +149,12 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
Quiet
|
||||
</span>
|
||||
)}
|
||||
{provider.daily_digest_enabled && (
|
||||
<span className="px-2 py-0.5 bg-emerald-500/20 text-emerald-400 text-xs rounded flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
Digest {provider.daily_digest_time}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Button */}
|
||||
@@ -332,6 +340,33 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Digest */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-emerald-400" />
|
||||
<p className="text-sm text-white">Daily Digest</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.daily_digest_enabled}
|
||||
onChange={(checked) => updateMutation.mutate({ daily_digest_enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider.daily_digest_enabled && (
|
||||
<div className="pl-4 border-l-2 border-bambu-dark-tertiary space-y-2">
|
||||
<p className="text-xs text-bambu-gray">Batch notifications into a single daily summary</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-bambu-gray" />
|
||||
<span className="text-sm text-white">
|
||||
Send at {formatTime(provider.daily_digest_time) || '08:00'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray">Edit provider to change digest time</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2 } from 'lucide-react';
|
||||
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import type { AppSettings, SmartPlug, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
|
||||
import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
import { SmartPlugCard } from '../components/SmartPlugCard';
|
||||
@@ -10,6 +10,8 @@ import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
|
||||
import { NotificationProviderCard } from '../components/NotificationProviderCard';
|
||||
import { AddNotificationModal } from '../components/AddNotificationModal';
|
||||
import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
|
||||
import { NotificationLogViewer } from '../components/NotificationLogViewer';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { SpoolmanSettings } from '../components/SpoolmanSettings';
|
||||
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
|
||||
import { availableLanguages } from '../i18n';
|
||||
@@ -26,9 +28,16 @@ export function SettingsPage() {
|
||||
const [showNotificationModal, setShowNotificationModal] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
|
||||
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
|
||||
const [showLogViewer, setShowLogViewer] = useState(false);
|
||||
const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications'>('general');
|
||||
|
||||
// Confirm modal states
|
||||
const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
|
||||
const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
|
||||
const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
|
||||
|
||||
const handleDefaultViewChange = (path: string) => {
|
||||
setDefaultViewState(path);
|
||||
setDefaultView(path);
|
||||
@@ -49,11 +58,62 @@ export function SettingsPage() {
|
||||
queryFn: api.getSmartPlugs,
|
||||
});
|
||||
|
||||
// Fetch energy data for all smart plugs when on the plugs tab
|
||||
const { data: plugEnergySummary, isLoading: energyLoading } = useQuery({
|
||||
queryKey: ['smart-plugs-energy', smartPlugs?.map(p => p.id)],
|
||||
queryFn: async () => {
|
||||
if (!smartPlugs || smartPlugs.length === 0) return null;
|
||||
const statuses = await Promise.all(
|
||||
smartPlugs.filter(p => p.enabled).map(async (plug) => {
|
||||
try {
|
||||
const status = await api.getSmartPlugStatus(plug.id);
|
||||
return { plug, status };
|
||||
} catch {
|
||||
return { plug, status: null as SmartPlugStatus | null };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Aggregate energy data
|
||||
let totalPower = 0;
|
||||
let totalToday = 0;
|
||||
let totalYesterday = 0;
|
||||
let totalLifetime = 0;
|
||||
let reachableCount = 0;
|
||||
|
||||
for (const { status } of statuses) {
|
||||
if (status?.reachable && status.energy) {
|
||||
reachableCount++;
|
||||
if (status.energy.power != null) totalPower += status.energy.power;
|
||||
if (status.energy.today != null) totalToday += status.energy.today;
|
||||
if (status.energy.yesterday != null) totalYesterday += status.energy.yesterday;
|
||||
if (status.energy.total != null) totalLifetime += status.energy.total;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalPower,
|
||||
totalToday,
|
||||
totalYesterday,
|
||||
totalLifetime,
|
||||
reachableCount,
|
||||
totalPlugs: smartPlugs.filter(p => p.enabled).length,
|
||||
};
|
||||
},
|
||||
enabled: activeTab === 'plugs' && !!smartPlugs && smartPlugs.length > 0,
|
||||
refetchInterval: activeTab === 'plugs' ? 10000 : false, // Refresh every 10s when on plugs tab
|
||||
});
|
||||
|
||||
const { data: notificationProviders, isLoading: providersLoading } = useQuery({
|
||||
queryKey: ['notification-providers'],
|
||||
queryFn: api.getNotificationProviders,
|
||||
});
|
||||
|
||||
const { data: printers } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
const { data: notificationTemplates, isLoading: templatesLoading } = useQuery({
|
||||
queryKey: ['notification-templates'],
|
||||
queryFn: api.getNotificationTemplates,
|
||||
@@ -95,6 +155,70 @@ export function SettingsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Test all notification providers
|
||||
const [testAllResult, setTestAllResult] = useState<{
|
||||
tested: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
results: Array<{
|
||||
provider_id: number;
|
||||
provider_name: string;
|
||||
provider_type: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
}>;
|
||||
} | null>(null);
|
||||
|
||||
const testAllMutation = useMutation({
|
||||
mutationFn: api.testAllNotificationProviders,
|
||||
onSuccess: (data) => {
|
||||
setTestAllResult(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
|
||||
if (data.failed === 0) {
|
||||
showToast(`All ${data.tested} providers tested successfully!`, 'success');
|
||||
} else {
|
||||
showToast(`${data.success}/${data.tested} providers succeeded`, data.failed > 0 ? 'error' : 'success');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(`Failed to test providers: ${error.message}`, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Bulk action for smart plugs
|
||||
const bulkPlugActionMutation = useMutation({
|
||||
mutationFn: async (action: 'on' | 'off') => {
|
||||
if (!smartPlugs) return { success: 0, failed: 0 };
|
||||
const enabledPlugs = smartPlugs.filter(p => p.enabled);
|
||||
const results = await Promise.all(
|
||||
enabledPlugs.map(async (plug) => {
|
||||
try {
|
||||
await api.controlSmartPlug(plug.id, action);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
})
|
||||
);
|
||||
return {
|
||||
success: results.filter(r => r.success).length,
|
||||
failed: results.filter(r => !r.success).length,
|
||||
};
|
||||
},
|
||||
onSuccess: (data, action) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['smart-plugs-energy'] });
|
||||
if (data.failed === 0) {
|
||||
showToast(`All ${data.success} plugs turned ${action}`, 'success');
|
||||
} else {
|
||||
showToast(`${data.success} plugs turned ${action}, ${data.failed} failed`, 'error');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(`Failed: ${error.message}`, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Ref for debounce timeout
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isInitialLoadRef = useRef(true);
|
||||
@@ -144,7 +268,10 @@ export function SettingsPage() {
|
||||
settings.ams_humidity_good !== localSettings.ams_humidity_good ||
|
||||
settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
|
||||
settings.ams_temp_good !== localSettings.ams_temp_good ||
|
||||
settings.ams_temp_fair !== localSettings.ams_temp_fair;
|
||||
settings.ams_temp_fair !== localSettings.ams_temp_fair ||
|
||||
settings.date_format !== localSettings.date_format ||
|
||||
settings.time_format !== localSettings.time_format ||
|
||||
settings.default_printer_id !== localSettings.default_printer_id;
|
||||
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
@@ -238,6 +365,120 @@ export function SettingsPage() {
|
||||
<div className="flex gap-8">
|
||||
{/* Left Column - General Settings */}
|
||||
<div className="space-y-6 flex-1 max-w-xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
<Globe className="w-4 h-4 inline mr-1" />
|
||||
{t('settings.language')}
|
||||
</label>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
{availableLanguages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.nativeName} ({lang.name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t('settings.languageDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
{t('settings.defaultView')}
|
||||
</label>
|
||||
<select
|
||||
value={defaultView}
|
||||
onChange={(e) => handleDefaultViewChange(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
{defaultNavItems.map((item) => (
|
||||
<option key={item.id} value={item.to}>
|
||||
{t(item.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t('settings.defaultViewDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Date Format
|
||||
</label>
|
||||
<select
|
||||
value={localSettings.date_format || 'system'}
|
||||
onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="system">System Default</option>
|
||||
<option value="us">US (MM/DD/YYYY)</option>
|
||||
<option value="eu">EU (DD/MM/YYYY)</option>
|
||||
<option value="iso">ISO (YYYY-MM-DD)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Time Format
|
||||
</label>
|
||||
<select
|
||||
value={localSettings.time_format || 'system'}
|
||||
onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="system">System Default</option>
|
||||
<option value="12h">12-hour (3:30 PM)</option>
|
||||
<option value="24h">24-hour (15:30)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Default Printer
|
||||
</label>
|
||||
<select
|
||||
value={localSettings.default_printer_id ?? ''}
|
||||
onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="">No default (ask each time)</option>
|
||||
{printers?.map((printer) => (
|
||||
<option key={printer.id} value={printer.id}>
|
||||
{printer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Pre-select this printer for uploads, reprints, and other operations.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Sidebar order</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Drag items in the sidebar to reorder. Reset to default order here.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleResetSidebarOrder}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">Archive Settings</h2>
|
||||
@@ -382,69 +623,6 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
<Globe className="w-4 h-4 inline mr-1" />
|
||||
{t('settings.language')}
|
||||
</label>
|
||||
<select
|
||||
value={i18n.language}
|
||||
onChange={(e) => i18n.changeLanguage(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
{availableLanguages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.nativeName} ({lang.name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t('settings.languageDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
{t('settings.defaultView')}
|
||||
</label>
|
||||
<select
|
||||
value={defaultView}
|
||||
onChange={(e) => handleDefaultViewChange(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
{defaultNavItems.map((item) => (
|
||||
<option key={item.id} value={item.to}>
|
||||
{t(item.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t('settings.defaultViewDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Sidebar order</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Drag items in the sidebar to reorder. Reset to default order here.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleResetSidebarOrder}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Second Column - AMS & Spoolman */}
|
||||
@@ -678,6 +856,121 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Data Management */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">Data Management</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Backup/Restore */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Backup Settings</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Export settings, providers, and plugs to JSON
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const backup = await api.exportBackup();
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `bambutrack-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Backup downloaded', 'success');
|
||||
} catch (err) {
|
||||
showToast('Failed to create backup', 'error');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Restore Settings</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Import settings from a backup file
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const result = await api.importBackup(file);
|
||||
if (result.success) {
|
||||
showToast(result.message, 'success');
|
||||
queryClient.invalidateQueries();
|
||||
} else {
|
||||
showToast(result.message, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Failed to restore backup', 'error');
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Clear Notification Logs</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Delete notification logs older than 30 days
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowClearLogsConfirm(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Clear Local Storage</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Clear browser cache (sidebar order, preferences)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowClearStorageConfirm(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -685,7 +978,7 @@ export function SettingsPage() {
|
||||
{/* Smart Plugs Tab */}
|
||||
{activeTab === 'plugs' && (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Plug className="w-5 h-5 text-bambu-green" />
|
||||
@@ -695,17 +988,144 @@ export function SettingsPage() {
|
||||
Connect Tasmota-based smart plugs to automate power control and track energy usage for your printers.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingPlug(null);
|
||||
setShowPlugModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Smart Plug
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 pt-1 shrink-0">
|
||||
{smartPlugs && smartPlugs.filter(p => p.enabled).length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setShowBulkPlugConfirm('on')}
|
||||
disabled={bulkPlugActionMutation.isPending}
|
||||
title="Turn all plugs on"
|
||||
>
|
||||
{bulkPlugActionMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Power className="w-4 h-4 text-bambu-green" />
|
||||
)}
|
||||
All On
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setShowBulkPlugConfirm('off')}
|
||||
disabled={bulkPlugActionMutation.isPending}
|
||||
title="Turn all plugs off"
|
||||
>
|
||||
{bulkPlugActionMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<PowerOff className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
All Off
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => {
|
||||
setEditingPlug(null);
|
||||
setShowPlugModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Smart Plug
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Energy Summary Card */}
|
||||
{smartPlugs && smartPlugs.length > 0 && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-yellow-400" />
|
||||
Energy Summary
|
||||
{energyLoading && (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-bambu-gray ml-2" />
|
||||
)}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{plugEnergySummary ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{/* Current Power */}
|
||||
<div className="bg-bambu-dark rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
Current Power
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{plugEnergySummary.totalPower.toFixed(1)}
|
||||
<span className="text-sm font-normal text-bambu-gray ml-1">W</span>
|
||||
</div>
|
||||
<div className="text-xs text-bambu-gray mt-1">
|
||||
{plugEnergySummary.reachableCount}/{plugEnergySummary.totalPlugs} plugs online
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today */}
|
||||
<div className="bg-bambu-dark rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
Today
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{plugEnergySummary.totalToday.toFixed(2)}
|
||||
<span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
|
||||
</div>
|
||||
{localSettings && localSettings.energy_cost_per_kwh > 0 && (
|
||||
<div className="text-xs text-bambu-gray mt-1">
|
||||
~{(plugEnergySummary.totalToday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Yesterday */}
|
||||
<div className="bg-bambu-dark rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
Yesterday
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{plugEnergySummary.totalYesterday.toFixed(2)}
|
||||
<span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
|
||||
</div>
|
||||
{localSettings && localSettings.energy_cost_per_kwh > 0 && (
|
||||
<div className="text-xs text-bambu-gray mt-1">
|
||||
~{(plugEnergySummary.totalYesterday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total Lifetime */}
|
||||
<div className="bg-bambu-dark rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
Total
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{plugEnergySummary.totalLifetime.toFixed(1)}
|
||||
<span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
|
||||
</div>
|
||||
{localSettings && localSettings.energy_cost_per_kwh > 0 && (
|
||||
<div className="text-xs text-bambu-gray mt-1">
|
||||
~{(plugEnergySummary.totalLifetime * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : !energyLoading ? (
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Enable plugs to see energy summary
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{plugsLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
|
||||
@@ -756,16 +1176,44 @@ export function SettingsPage() {
|
||||
<Bell className="w-5 h-5 text-bambu-green" />
|
||||
Providers
|
||||
</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingProvider(null);
|
||||
setShowNotificationModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setShowLogViewer(true)}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
Log
|
||||
</Button>
|
||||
{notificationProviders && notificationProviders.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setTestAllResult(null);
|
||||
testAllMutation.mutate();
|
||||
}}
|
||||
disabled={testAllMutation.isPending}
|
||||
>
|
||||
{testAllMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
Test All
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingProvider(null);
|
||||
setShowNotificationModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Language Setting */}
|
||||
@@ -791,6 +1239,44 @@ export function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Test All Results */}
|
||||
{testAllResult && (
|
||||
<Card className="mb-4">
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-white">Test Results</span>
|
||||
<button
|
||||
onClick={() => setTestAllResult(null)}
|
||||
className="text-bambu-gray hover:text-white text-xs"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm mb-2">
|
||||
<span className="flex items-center gap-1 text-bambu-green">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{testAllResult.success} passed
|
||||
</span>
|
||||
{testAllResult.failed > 0 && (
|
||||
<span className="flex items-center gap-1 text-red-400">
|
||||
<XCircle className="w-4 h-4" />
|
||||
{testAllResult.failed} failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{testAllResult.results.filter(r => !r.success).length > 0 && (
|
||||
<div className="space-y-1 mt-2 pt-2 border-t border-bambu-dark-tertiary">
|
||||
{testAllResult.results.filter(r => !r.success).map((result) => (
|
||||
<div key={result.provider_id} className="text-xs text-red-400">
|
||||
<span className="font-medium">{result.provider_name}:</span> {result.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{providersLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
|
||||
@@ -918,6 +1404,66 @@ export function SettingsPage() {
|
||||
onClose={() => setEditingTemplate(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notification Log Viewer */}
|
||||
{showLogViewer && (
|
||||
<NotificationLogViewer
|
||||
onClose={() => setShowLogViewer(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm Modal: Clear Notification Logs */}
|
||||
{showClearLogsConfirm && (
|
||||
<ConfirmModal
|
||||
title="Clear Notification Logs"
|
||||
message="This will permanently delete all notification logs older than 30 days. This action cannot be undone."
|
||||
confirmText="Clear Logs"
|
||||
variant="warning"
|
||||
onConfirm={async () => {
|
||||
setShowClearLogsConfirm(false);
|
||||
try {
|
||||
const result = await api.clearNotificationLogs(30);
|
||||
showToast(result.message, 'success');
|
||||
} catch {
|
||||
showToast('Failed to clear logs', 'error');
|
||||
}
|
||||
}}
|
||||
onCancel={() => setShowClearLogsConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm Modal: Clear Local Storage */}
|
||||
{showClearStorageConfirm && (
|
||||
<ConfirmModal
|
||||
title="Clear All Local Storage"
|
||||
message="WARNING: This will clear ALL browser data for Bambusy including your sidebar order, preferences, and cached data. The page will reload after clearing. This action cannot be undone!"
|
||||
confirmText="Clear Everything"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
setShowClearStorageConfirm(false);
|
||||
localStorage.clear();
|
||||
showToast('Local storage cleared. Refreshing...', 'success');
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
}}
|
||||
onCancel={() => setShowClearStorageConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm Modal: Bulk Plug Action */}
|
||||
{showBulkPlugConfirm && (
|
||||
<ConfirmModal
|
||||
title={`Turn All Plugs ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
|
||||
message={`This will turn ${showBulkPlugConfirm === 'on' ? 'ON' : 'OFF'} all ${smartPlugs?.filter(p => p.enabled).length || 0} enabled smart plugs. ${showBulkPlugConfirm === 'off' ? 'Any running printers may be affected!' : ''}`}
|
||||
confirmText={`Turn All ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
|
||||
variant={showBulkPlugConfirm === 'off' ? 'danger' : 'warning'}
|
||||
onConfirm={() => {
|
||||
const action = showBulkPlugConfirm;
|
||||
setShowBulkPlugConfirm(null);
|
||||
bulkPlugActionMutation.mutate(action);
|
||||
}}
|
||||
onCancel={() => setShowBulkPlugConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user