Added notification module

This commit is contained in:
Martin Ziegler
2025-11-30 16:51:27 +01:00
parent 301eec98fb
commit 17c37a7ccd
17 changed files with 2760 additions and 412 deletions
+186
View File
@@ -0,0 +1,186 @@
# Notifications Feature Implementation Plan
## Overview
Add push notifications for print events (start, complete, fail) with support for multiple notification providers.
## Supported Providers (Initial Release)
1. **CallMeBot/WhatsApp** - Free, uses HTTP API with phone number + API key
2. **ntfy** - Self-hosted or ntfy.sh, simple HTTP POST
3. **Pushover** - Commercial ($5 one-time), HTTP API with user key + app token
4. **Telegram** - Free bot API, requires bot token + chat ID
5. **Email (SMTP)** - Universal fallback
## Database Design
### New Table: `notification_providers`
```sql
CREATE TABLE notification_providers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL, -- User-defined name ("My WhatsApp")
provider_type TEXT NOT NULL, -- "callmebot", "ntfy", "pushover", "telegram", "email"
enabled BOOLEAN DEFAULT true,
-- Provider-specific config (JSON or individual fields)
config TEXT NOT NULL, -- JSON: {"phone": "+1234", "apikey": "xxx"}
-- Event triggers (which events send notifications)
on_print_start BOOLEAN DEFAULT false,
on_print_complete BOOLEAN DEFAULT true,
on_print_failed BOOLEAN DEFAULT true,
-- Optional: Link to specific printer (NULL = all printers)
printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
-- Timestamps
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### Config JSON Structure by Provider
```python
# CallMeBot/WhatsApp
{"phone": "+1234567890", "apikey": "123456"}
# ntfy
{"server": "https://ntfy.sh", "topic": "my-printer", "auth_token": "optional"}
# Pushover
{"user_key": "xxx", "app_token": "yyy", "priority": 0}
# Telegram
{"bot_token": "123:ABC", "chat_id": "12345678"}
# Email (SMTP)
{"smtp_server": "smtp.gmail.com", "smtp_port": 587, "username": "x", "password": "y", "from_email": "x@gmail.com", "to_email": "dest@example.com"}
```
## Backend Implementation
### 1. Model: `backend/app/models/notification.py`
- SQLAlchemy model for `notification_providers` table
- Relationship to Printer (optional, nullable)
### 2. Schema: `backend/app/schemas/notification.py`
- `NotificationProviderBase` - Common fields
- `NotificationProviderCreate` - For creating new providers
- `NotificationProviderUpdate` - For partial updates
- `NotificationProviderResponse` - API response with id/timestamps
- `NotificationTestRequest` - For testing notifications
### 3. Service: `backend/app/services/notification_service.py`
Core notification dispatcher with provider implementations:
```python
class NotificationService:
async def send_notification(self, provider: NotificationProvider, event: str, data: dict) -> bool
async def on_print_start(self, printer_id: int, data: dict, db: AsyncSession)
async def on_print_complete(self, printer_id: int, status: str, data: dict, db: AsyncSession)
# Provider-specific methods
async def _send_callmebot(self, config: dict, message: str) -> bool
async def _send_ntfy(self, config: dict, title: str, message: str) -> bool
async def _send_pushover(self, config: dict, title: str, message: str) -> bool
async def _send_telegram(self, config: dict, message: str) -> bool
async def _send_email(self, config: dict, subject: str, body: str) -> bool
```
### 4. Routes: `backend/app/api/routes/notifications.py`
```
GET /notifications/ - List all providers
POST /notifications/ - Create provider
GET /notifications/{id} - Get provider details
PATCH /notifications/{id} - Update provider
DELETE /notifications/{id} - Delete provider
POST /notifications/{id}/test - Send test notification
POST /notifications/test-config - Test config before saving
```
### 5. Integration in `main.py`
Add calls to notification service in existing event handlers:
- `on_print_start()` - After smart_plug_manager call (line ~244)
- `on_print_complete()` - After archive update (line ~580)
## Frontend Implementation
### 1. API Client: `frontend/src/api/client.ts`
Add types and API methods for notification providers.
### 2. Components
- `NotificationProviderCard.tsx` - Display single provider with enable/disable toggle
- `AddNotificationModal.tsx` - Modal for adding/editing providers with provider-specific forms
### 3. Settings Page Integration
Add "Notifications" section in SettingsPage.tsx (similar to Smart Plugs section):
- List of configured providers
- Add button
- Per-provider enable/disable
- Test button
- Event toggles (start/complete/failed)
## Message Templates
### Print Started
```
🖨️ Print Started
{printer_name}: {filename}
Estimated time: {est_time}
```
### Print Completed
```
✅ Print Completed
{printer_name}: {filename}
Time: {actual_time}
Filament: {filament_used}g
```
### Print Failed
```
❌ Print Failed
{printer_name}: {filename}
Status: {failure_reason}
Progress: {progress}%
```
## Implementation Order
### Phase 1: Backend Core
1. Create notification model with migrations
2. Create notification schema
3. Create notification service with all 5 providers
4. Create notification routes (CRUD + test)
5. Register routes in main.py
6. Integrate into print event handlers
### Phase 2: Frontend
7. Add API types and methods
8. Create NotificationProviderCard component
9. Create AddNotificationModal component
10. Add Notifications section to SettingsPage
### Phase 3: Testing & Polish
11. Test each provider
12. Add error handling and logging
13. Handle network failures gracefully (don't block print events)
## Technical Notes
### Async HTTP Requests
Use `httpx` (already available) for async HTTP calls to notification APIs.
### Error Handling
- Notifications should NEVER block print events
- Log failures but continue processing
- Store last_error and last_success timestamps for UI feedback
### Security
- Store credentials in database (SQLite file already contains access codes)
- Consider encryption for sensitive fields in future
### Rate Limiting
- Debounce rapid events (don't spam on quick start/stop cycles)
- Consider per-provider rate limits
## Questions for User
None - proceeding with the 5 providers as discussed.
+95 -1
View File
@@ -79,6 +79,15 @@ v∆v
- Search by profile name or filament
- Dual-nozzle support for H2 series (auto-detected from MQTT)
- Left/Right extruder column layout for dual-nozzle printers
- **Push Notifications** - Get notified about print events via multiple channels:
- WhatsApp (via CallMeBot)
- ntfy (self-hosted or ntfy.sh)
- Pushover
- Telegram
- Email (SMTP with TLS/SSL/plain options)
- Configurable event triggers (start, complete, failed, stopped, progress milestones)
- Quiet hours to suppress notifications during sleep
- Per-printer filtering
- **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
- **File Manager** - Browse and manage files on your printer's SD card
- **Re-print** - Send archived prints back to any connected printer
@@ -528,6 +537,91 @@ Each plug card shows:
- On/Off buttons for manual control
- Expandable settings panel
### Push Notifications
Bambusy can send push notifications when print events occur. Notifications are useful for monitoring prints remotely without checking the app constantly.
#### Supported Providers
| Provider | Description | Setup Required |
|----------|-------------|----------------|
| **WhatsApp** | Via [CallMeBot](https://www.callmebot.com/blog/free-api-whatsapp-messages/) | Free API key from CallMeBot |
| **ntfy** | Self-hosted or [ntfy.sh](https://ntfy.sh) | Just a topic name (no account needed for public server) |
| **Pushover** | [Pushover](https://pushover.net/) push notifications | Pushover account + app token |
| **Telegram** | Via Telegram Bot | Bot token from @BotFather |
| **Email** | SMTP email | SMTP server credentials |
#### Adding a Notification Provider
1. Go to **Settings** > **Notifications**
2. Click **Add Provider**
3. Select a provider type and enter the required configuration
4. Click **Send Test** to verify the configuration works
5. Configure which events should trigger notifications
6. Click **Add**
#### Event Triggers
Configure which events send notifications:
| Event | Description |
|-------|-------------|
| **Print Started** | When a print job begins |
| **Print Completed** | When a print finishes successfully |
| **Print Failed** | When a print fails or errors out |
| **Print Stopped** | When you manually stop/cancel a print |
| **Progress Milestones** | At 25%, 50%, and 75% progress |
| **Printer Offline** | When a printer disconnects |
| **Printer Error** | When HMS errors are detected |
| **Low Filament** | When filament is running low |
#### Quiet Hours
Enable quiet hours to suppress notifications during sleep or work hours:
1. Enable **Quiet Hours** toggle
2. Set start time (e.g., 22:00)
3. Set end time (e.g., 07:00)
Notifications during quiet hours are silently skipped.
#### Per-Printer Filtering
By default, notifications are sent for all printers. To limit notifications to a specific printer:
1. Open the notification provider settings
2. Select a printer from the **Printer** dropdown
3. Only events from that printer will trigger notifications
#### Provider Setup Guides
**WhatsApp (CallMeBot):**
1. Add CallMeBot to your contacts: +34 644 51 95 23
2. Send "I allow callmebot to send me messages" via WhatsApp
3. You'll receive an API key
4. Enter your phone number (with country code) and API key in Bambusy
**ntfy:**
1. Choose a unique topic name (e.g., `my-printer-alerts-xyz123`)
2. Subscribe to it on your phone using the ntfy app or web interface
3. Enter the topic name in Bambusy (server defaults to ntfy.sh)
**Pushover:**
1. Create an account at [pushover.net](https://pushover.net/)
2. Create an application to get an API token
3. Enter your user key and app token in Bambusy
**Telegram:**
1. Message @BotFather on Telegram to create a bot
2. Get your chat ID by messaging @userinfobot
3. Enter the bot token and chat ID in Bambusy
**Email:**
1. Configure your SMTP server settings
2. For Gmail, use an App Password (not your regular password)
3. Choose security mode: STARTTLS (port 587), SSL (port 465), or None (port 25)
4. Enable/disable authentication as needed
## Tech Stack
- **Backend**: Python / FastAPI
@@ -699,8 +793,8 @@ To fix the printer's clock:
- [x] Print scheduling and queuing
- [x] Automatic finish photo capture
- [x] K-Profiles management (pressure advance)
- [x] Push notifications (WhatsApp, ntfy, Pushover, Telegram, Email)
- [ ] Maintenance tracker
- [ ] Notifications (email, push)
- [ ] Mobile-optimized UI
## License
+223
View File
@@ -0,0 +1,223 @@
"""API routes for notification providers."""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import 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.schemas.notification import (
NotificationProviderCreate,
NotificationProviderResponse,
NotificationProviderUpdate,
NotificationTestRequest,
NotificationTestResponse,
)
from backend.app.services.notification_service import notification_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notifications", tags=["notifications"])
def _provider_to_dict(provider: NotificationProvider) -> dict:
"""Convert a NotificationProvider model to a response dictionary."""
return {
"id": provider.id,
"name": provider.name,
"provider_type": provider.provider_type,
"enabled": provider.enabled,
"config": json.loads(provider.config) if isinstance(provider.config, str) else provider.config,
# Print lifecycle events
"on_print_start": provider.on_print_start,
"on_print_complete": provider.on_print_complete,
"on_print_failed": provider.on_print_failed,
"on_print_progress": provider.on_print_progress,
# Printer status events
"on_printer_offline": provider.on_printer_offline,
"on_printer_error": provider.on_printer_error,
"on_filament_low": provider.on_filament_low,
# Quiet hours
"quiet_hours_enabled": provider.quiet_hours_enabled,
"quiet_hours_start": provider.quiet_hours_start,
"quiet_hours_end": provider.quiet_hours_end,
# Printer filter
"printer_id": provider.printer_id,
# Status tracking
"last_success": provider.last_success,
"last_error": provider.last_error,
"last_error_at": provider.last_error_at,
# Timestamps
"created_at": provider.created_at,
"updated_at": provider.updated_at,
}
@router.get("/", response_model=list[NotificationProviderResponse])
async def list_notification_providers(db: AsyncSession = Depends(get_db)):
"""List all notification providers."""
result = await db.execute(
select(NotificationProvider).order_by(NotificationProvider.created_at.desc())
)
providers = result.scalars().all()
return [_provider_to_dict(provider) for provider in providers]
@router.post("/", response_model=NotificationProviderResponse)
async def create_notification_provider(
provider_data: NotificationProviderCreate,
db: AsyncSession = Depends(get_db),
):
"""Create a new notification provider."""
provider = NotificationProvider(
name=provider_data.name,
provider_type=provider_data.provider_type.value,
enabled=provider_data.enabled,
config=json.dumps(provider_data.config),
# Print lifecycle events
on_print_start=provider_data.on_print_start,
on_print_complete=provider_data.on_print_complete,
on_print_failed=provider_data.on_print_failed,
on_print_progress=provider_data.on_print_progress,
# Printer status events
on_printer_offline=provider_data.on_printer_offline,
on_printer_error=provider_data.on_printer_error,
on_filament_low=provider_data.on_filament_low,
# Quiet hours
quiet_hours_enabled=provider_data.quiet_hours_enabled,
quiet_hours_start=provider_data.quiet_hours_start,
quiet_hours_end=provider_data.quiet_hours_end,
# Printer filter
printer_id=provider_data.printer_id,
)
db.add(provider)
await db.commit()
await db.refresh(provider)
logger.info(f"Created notification provider: {provider.name} ({provider.provider_type})")
return _provider_to_dict(provider)
@router.get("/{provider_id}", response_model=NotificationProviderResponse)
async def get_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get a specific notification provider."""
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == provider_id)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Notification provider not found")
return _provider_to_dict(provider)
@router.patch("/{provider_id}", response_model=NotificationProviderResponse)
async def update_notification_provider(
provider_id: int,
update_data: NotificationProviderUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a notification provider."""
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == provider_id)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Notification provider not found")
# Update only provided fields
update_dict = update_data.model_dump(exclude_unset=True)
for key, value in update_dict.items():
if key == "config" and value is not None:
setattr(provider, key, json.dumps(value))
elif key == "provider_type" and value is not None:
setattr(provider, key, value.value)
else:
setattr(provider, key, value)
await db.commit()
await db.refresh(provider)
logger.info(f"Updated notification provider: {provider.name}")
return _provider_to_dict(provider)
@router.delete("/{provider_id}")
async def delete_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
):
"""Delete a notification provider."""
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == provider_id)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Notification provider not found")
name = provider.name
await db.delete(provider)
await db.commit()
logger.info(f"Deleted notification provider: {name}")
return {"message": f"Notification provider '{name}' deleted"}
@router.post("/{provider_id}/test", response_model=NotificationTestResponse)
async def test_notification_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
):
"""Send a test notification using an existing provider."""
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == provider_id)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Notification provider not found")
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:
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)
+10 -1
View File
@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
async def init_db():
# Import models to register them with SQLAlchemy
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue # noqa: F401
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -73,3 +73,12 @@ async def run_migrations(conn):
except Exception:
# Column already exists
pass
# Migration: Add on_print_stopped column to notification_providers
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"
))
except Exception:
# Column already exists
pass
+36 -1
View File
@@ -54,8 +54,9 @@ from fastapi.responses import FileResponse
from backend.app.core.database import init_db, async_session
from sqlalchemy import select, or_
from backend.app.core.websocket import ws_manager
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications
from backend.app.api.routes import settings as settings_routes
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import (
printer_manager,
printer_state_to_dict,
@@ -431,6 +432,20 @@ async def on_print_start(printer_id: int, data: dict):
import logging
logging.getLogger(__name__).warning(f"Smart plug on_print_start failed: {e}")
# Send print start notifications
try:
async with async_session() as db:
from backend.app.models.printer import Printer
result = await db.execute(
select(Printer).where(Printer.id == printer_id)
)
printer = result.scalar_one_or_none()
printer_name = printer.name if printer else f"Printer {printer_id}"
await notification_service.on_print_start(printer_id, printer_name, data, db)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Notification on_print_start failed: {e}")
async def on_print_complete(printer_id: int, data: dict):
"""Handle print completion - update the archive status."""
@@ -668,6 +683,25 @@ async def on_print_complete(printer_id: int, data: dict):
import logging
logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}")
# Send print complete notifications
try:
async with async_session() as db:
from backend.app.models.printer import Printer
result = await db.execute(
select(Printer).where(Printer.id == printer_id)
)
printer = result.scalar_one_or_none()
printer_name = printer.name if printer else f"Printer {printer_id}"
status = data.get("status", "completed")
# on_print_complete handles all status types: completed, failed, aborted, stopped
await notification_service.on_print_complete(
printer_id, printer_name, status, data, db
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Notification on_print_complete failed: {e}")
# Update queue item if this was a scheduled print
try:
async with async_session() as db:
@@ -762,6 +796,7 @@ app.include_router(cloud.router, prefix=app_settings.api_prefix)
app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
app.include_router(print_queue.router, prefix=app_settings.api_prefix)
app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
app.include_router(notifications.router, prefix=app_settings.api_prefix)
app.include_router(websocket.router, prefix=app_settings.api_prefix)
+54
View File
@@ -0,0 +1,54 @@
"""Notification provider model for push notifications."""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, Time
from sqlalchemy.orm import relationship
from backend.app.core.database import Base
class NotificationProvider(Base):
"""Model for notification providers (WhatsApp, ntfy, Pushover, etc.)."""
__tablename__ = "notification_providers"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False) # User-defined name
provider_type = Column(String(50), nullable=False) # callmebot, ntfy, pushover, telegram, email
enabled = Column(Boolean, default=True)
# Provider-specific configuration stored as JSON string
config = Column(Text, nullable=False)
# Event triggers - print lifecycle
on_print_start = Column(Boolean, default=False)
on_print_complete = Column(Boolean, default=True)
on_print_failed = Column(Boolean, default=True)
on_print_stopped = Column(Boolean, default=True) # User cancelled/stopped print
on_print_progress = Column(Boolean, default=False) # 25%, 50%, 75% milestones
# Event triggers - printer status
on_printer_offline = Column(Boolean, default=False)
on_printer_error = Column(Boolean, default=False) # AMS issues, etc.
on_filament_low = Column(Boolean, default=False)
# Quiet hours (do not disturb)
quiet_hours_enabled = Column(Boolean, default=False)
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"
# Optional: Link to specific printer (NULL = all printers)
printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
# Status tracking
last_success = Column(DateTime, nullable=True)
last_error = Column(Text, nullable=True)
last_error_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
printer = relationship("Printer", back_populates="notification_providers")
+4
View File
@@ -31,7 +31,11 @@ class Printer(Base):
smart_plug: Mapped["SmartPlug | None"] = relationship(
back_populates="printer", uselist=False
)
notification_providers: Mapped[list["NotificationProvider"]] = relationship(
back_populates="printer"
)
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.smart_plug import SmartPlug # noqa: E402
from backend.app.models.notification import NotificationProvider # noqa: E402
+168
View File
@@ -0,0 +1,168 @@
"""Pydantic schemas for notification providers."""
from datetime import datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field, field_validator
class ProviderType(str, Enum):
"""Supported notification provider types."""
CALLMEBOT = "callmebot"
NTFY = "ntfy"
PUSHOVER = "pushover"
TELEGRAM = "telegram"
EMAIL = "email"
class NotificationProviderBase(BaseModel):
"""Base schema for notification providers."""
name: str = Field(..., min_length=1, max_length=100, description="User-defined name")
provider_type: ProviderType = Field(..., description="Type of notification provider")
enabled: bool = Field(default=True, description="Whether notifications are enabled")
config: dict[str, Any] = Field(..., description="Provider-specific configuration")
# Event triggers - print lifecycle
on_print_start: bool = Field(default=False, description="Notify on print start")
on_print_complete: bool = Field(default=True, description="Notify on print complete")
on_print_failed: bool = Field(default=True, description="Notify on print failed")
on_print_stopped: bool = Field(default=True, description="Notify when print is stopped/cancelled")
on_print_progress: bool = Field(default=False, description="Notify at 25%, 50%, 75% progress")
# Event triggers - printer status
on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
on_printer_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)")
on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
# Quiet hours
quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
quiet_hours_end: str | None = Field(default=None, description="End time 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")
@classmethod
def validate_time_format(cls, v: str | None) -> str | None:
if v is None:
return v
try:
parts = v.split(":")
if len(parts) != 2:
raise ValueError("Invalid time format")
hour, minute = int(parts[0]), int(parts[1])
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError("Invalid time range")
return f"{hour:02d}:{minute:02d}"
except (ValueError, TypeError):
raise ValueError("Time must be in HH:MM format (e.g., 22:00)")
class NotificationProviderCreate(NotificationProviderBase):
"""Schema for creating a notification provider."""
pass
class NotificationProviderUpdate(BaseModel):
"""Schema for updating a notification provider (all fields optional)."""
name: str | None = Field(default=None, min_length=1, max_length=100)
provider_type: ProviderType | None = None
enabled: bool | None = None
config: dict[str, Any] | None = None
# Event triggers - print lifecycle
on_print_start: bool | None = None
on_print_complete: bool | None = None
on_print_failed: bool | None = None
on_print_stopped: bool | None = None
on_print_progress: bool | None = None
# Event triggers - printer status
on_printer_offline: bool | None = None
on_printer_error: bool | None = None
on_filament_low: bool | None = None
# Quiet hours
quiet_hours_enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
# Printer filter
printer_id: int | None = None
class NotificationProviderResponse(NotificationProviderBase):
"""Schema for notification provider API responses."""
id: int
last_success: datetime | None = None
last_error: str | None = None
last_error_at: datetime | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class NotificationTestRequest(BaseModel):
"""Schema for testing notification configuration."""
provider_type: ProviderType
config: dict[str, Any]
class NotificationTestResponse(BaseModel):
"""Schema for test notification response."""
success: bool
message: str
# Provider-specific config schemas for documentation/validation reference
class CallMeBotConfig(BaseModel):
"""CallMeBot/WhatsApp configuration."""
phone: str = Field(..., description="Phone number with country code (e.g., +1234567890)")
apikey: str = Field(..., description="API key from CallMeBot")
class NtfyConfig(BaseModel):
"""ntfy configuration."""
server: str = Field(default="https://ntfy.sh", description="ntfy server URL")
topic: str = Field(..., description="Topic name to publish to")
auth_token: str | None = Field(default=None, description="Optional authentication token")
class PushoverConfig(BaseModel):
"""Pushover configuration."""
user_key: str = Field(..., description="Your Pushover user key")
app_token: str = Field(..., description="Your Pushover application token")
priority: int = Field(default=0, ge=-2, le=2, description="Message priority (-2 to 2)")
class TelegramConfig(BaseModel):
"""Telegram bot configuration."""
bot_token: str = Field(..., description="Bot token from @BotFather")
chat_id: str = Field(..., description="Chat ID to send messages to")
class EmailConfig(BaseModel):
"""Email/SMTP configuration."""
smtp_server: str = Field(..., description="SMTP server hostname")
smtp_port: int = Field(default=587, description="SMTP port (587 for TLS, 465 for SSL)")
username: str = Field(..., description="SMTP username/email")
password: str = Field(..., description="SMTP password or app password")
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")
@@ -0,0 +1,534 @@
"""Notification service for sending push notifications via various providers."""
import json
import logging
import smtplib
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any
from urllib.parse import quote
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.notification import NotificationProvider
logger = logging.getLogger(__name__)
class NotificationService:
"""Service for sending notifications through various providers."""
def __init__(self):
self._http_client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(timeout=30.0)
return self._http_client
async def close(self):
"""Close HTTP client."""
if self._http_client and not self._http_client.is_closed:
await self._http_client.aclose()
def _is_in_quiet_hours(self, provider: NotificationProvider) -> bool:
"""Check if current time is within provider's quiet hours."""
if not provider.quiet_hours_enabled:
return False
if not provider.quiet_hours_start or not provider.quiet_hours_end:
return False
try:
now = datetime.now()
current_time = now.hour * 60 + now.minute
start_parts = provider.quiet_hours_start.split(":")
end_parts = provider.quiet_hours_end.split(":")
start_minutes = int(start_parts[0]) * 60 + int(start_parts[1])
end_minutes = int(end_parts[0]) * 60 + int(end_parts[1])
# Handle overnight quiet hours (e.g., 22:00 to 07:00)
if start_minutes > end_minutes:
# Quiet hours span midnight
return current_time >= start_minutes or current_time < end_minutes
else:
# Same day quiet hours
return start_minutes <= current_time < end_minutes
except (ValueError, TypeError, AttributeError):
logger.warning(f"Invalid quiet hours format for provider {provider.name}")
return False
def _format_duration(self, seconds: int | None) -> str:
"""Format duration in seconds to human-readable string."""
if seconds is None:
return "Unknown"
hours = seconds // 3600
minutes = (seconds % 3600) // 60
if hours > 0:
return f"{hours}h {minutes}m"
return f"{minutes}m"
def _build_print_start_message(self, printer_name: str, data: dict) -> tuple[str, str]:
"""Build notification message for print start event."""
filename = data.get("filename", "Unknown")
# Clean up filename
if filename.endswith(".gcode.3mf"):
filename = filename[:-10]
elif filename.endswith(".3mf"):
filename = filename[:-4]
title = "Print Started"
estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
time_str = self._format_duration(estimated_time * 60 if estimated_time else None)
message = f"{printer_name}: {filename}\nEstimated: {time_str}"
return title, message
def _build_print_complete_message(
self, printer_name: str, status: str, data: dict, archive_data: dict | None = None
) -> tuple[str, str]:
"""Build notification message for print complete event."""
filename = data.get("filename", "Unknown")
if filename.endswith(".gcode.3mf"):
filename = filename[:-10]
elif filename.endswith(".3mf"):
filename = filename[:-4]
if status == "completed":
title = "Print Completed"
elif status == "failed":
title = "Print Failed"
elif status in ("aborted", "stopped", "cancelled"):
title = "Print Stopped"
else:
title = "Print Ended"
lines = [f"{printer_name}: {filename}"]
if archive_data:
# Add print time if available
if archive_data.get("print_time_seconds"):
lines.append(f"Time: {self._format_duration(archive_data['print_time_seconds'])}")
# Add filament used if available
if archive_data.get("actual_filament_grams"):
lines.append(f"Filament: {archive_data['actual_filament_grams']:.1f}g")
# Add failure reason if failed
if status == "failed" and archive_data.get("failure_reason"):
lines.append(f"Reason: {archive_data['failure_reason']}")
message = "\n".join(lines)
return title, message
def _build_progress_message(
self, printer_name: str, filename: str, progress: int
) -> tuple[str, str]:
"""Build notification message for print progress milestone."""
if filename.endswith(".gcode.3mf"):
filename = filename[:-10]
elif filename.endswith(".3mf"):
filename = filename[:-4]
title = f"Print {progress}% Complete"
message = f"{printer_name}: {filename}"
return title, message
def _build_printer_offline_message(self, printer_name: str) -> tuple[str, str]:
"""Build notification message for printer offline event."""
title = "Printer Offline"
message = f"{printer_name} has disconnected"
return title, message
def _build_printer_error_message(
self, printer_name: str, error_type: str, error_detail: str | None = None
) -> tuple[str, str]:
"""Build notification message for printer error event."""
title = f"Printer Error: {error_type}"
message = f"{printer_name}"
if error_detail:
message += f"\n{error_detail}"
return title, message
def _build_filament_low_message(
self, printer_name: str, slot: int, remaining_percent: int
) -> tuple[str, str]:
"""Build notification message for low filament event."""
title = "Filament Low"
message = f"{printer_name}: Slot {slot} at {remaining_percent}%"
return title, message
async def send_test_notification(
self, provider_type: str, config: dict[str, Any]
) -> tuple[bool, str]:
"""Send a test notification to verify configuration."""
title = "BambuTrack Test"
message = "This is a test notification from BambuTrack. If you see this, notifications are working correctly!"
try:
if provider_type == "callmebot":
return await self._send_callmebot(config, f"{title}\n{message}")
elif provider_type == "ntfy":
return await self._send_ntfy(config, title, message)
elif provider_type == "pushover":
return await self._send_pushover(config, title, message)
elif provider_type == "telegram":
return await self._send_telegram(config, f"*{title}*\n{message}")
elif provider_type == "email":
return await self._send_email(config, title, message)
else:
return False, f"Unknown provider type: {provider_type}"
except Exception as e:
logger.exception(f"Error sending test notification via {provider_type}")
return False, str(e)
async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]:
"""Send notification via CallMeBot (WhatsApp)."""
phone = config.get("phone", "").strip()
apikey = config.get("apikey", "").strip()
if not phone or not apikey:
return False, "Phone number and API key are required"
# URL encode the message
encoded_message = quote(message)
url = f"https://api.callmebot.com/whatsapp.php?phone={phone}&text={encoded_message}&apikey={apikey}"
client = await self._get_client()
response = await client.get(url)
if response.status_code == 200:
return True, "Message sent successfully"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_ntfy(self, config: dict, title: str, message: str) -> tuple[bool, str]:
"""Send notification via ntfy."""
server = config.get("server", "https://ntfy.sh").rstrip("/")
topic = config.get("topic", "").strip()
auth_token = config.get("auth_token", "").strip()
if not topic:
return False, "Topic is required"
url = f"{server}/{topic}"
headers = {"Title": title}
if auth_token:
headers["Authorization"] = f"Bearer {auth_token}"
client = await self._get_client()
response = await client.post(url, content=message, headers=headers)
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_pushover(self, config: dict, title: str, message: str) -> tuple[bool, str]:
"""Send notification via Pushover."""
user_key = config.get("user_key", "").strip()
app_token = config.get("app_token", "").strip()
priority = config.get("priority", 0)
if not user_key or not app_token:
return False, "User key and app token are required"
url = "https://api.pushover.net/1/messages.json"
data = {
"token": app_token,
"user": user_key,
"title": title,
"message": message,
"priority": priority,
}
client = await self._get_client()
response = await client.post(url, data=data)
if response.status_code == 200:
return True, "Message sent successfully"
else:
try:
error_data = response.json()
errors = error_data.get("errors", [])
return False, f"Pushover error: {', '.join(errors)}"
except Exception:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_telegram(self, config: dict, message: str) -> tuple[bool, str]:
"""Send notification via Telegram bot."""
bot_token = config.get("bot_token", "").strip()
chat_id = config.get("chat_id", "").strip()
if not bot_token or not chat_id:
return False, "Bot token and chat ID are required"
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
data = {
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown",
}
client = await self._get_client()
response = await client.post(url, json=data)
if response.status_code == 200:
result = response.json()
if result.get("ok"):
return True, "Message sent successfully"
else:
return False, f"Telegram error: {result.get('description', 'Unknown error')}"
else:
return False, f"HTTP {response.status_code}: {response.text[:200]}"
async def _send_email(self, config: dict, subject: str, body: str) -> tuple[bool, str]:
"""Send notification via email (SMTP)."""
smtp_server = config.get("smtp_server", "").strip()
smtp_port = int(config.get("smtp_port", 587))
username = config.get("username", "").strip()
password = config.get("password", "").strip()
from_email = config.get("from_email", "").strip()
to_email = config.get("to_email", "").strip()
# Security: "starttls" (port 587), "ssl" (port 465), "none" (port 25)
security = config.get("security", "starttls")
# Authentication: "true" or "false"
auth_enabled = config.get("auth_enabled", "true").lower() == "true"
if not all([smtp_server, from_email, to_email]):
return False, "SMTP server, from email, and to email are required"
if auth_enabled and not all([username, password]):
return False, "Username and password are required when authentication is enabled"
try:
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = to_email
msg["Subject"] = f"[BambuTrack] {subject}"
msg.attach(MIMEText(body, "plain"))
if security == "ssl":
# Direct SSL connection (typically port 465)
server = smtplib.SMTP_SSL(smtp_server, smtp_port)
elif security == "starttls":
# STARTTLS upgrade (typically port 587)
server = smtplib.SMTP(smtp_server, smtp_port)
server.starttls()
else:
# No encryption (typically port 25) - use with caution
server = smtplib.SMTP(smtp_server, smtp_port)
if auth_enabled:
server.login(username, password)
server.sendmail(from_email, to_email, msg.as_string())
server.quit()
return True, "Email sent successfully"
except smtplib.SMTPAuthenticationError:
return False, "SMTP authentication failed - check username/password"
except smtplib.SMTPException as e:
return False, f"SMTP error: {str(e)}"
except Exception as e:
return False, f"Email error: {str(e)}"
async def _send_to_provider(
self, provider: NotificationProvider, title: str, message: str
) -> tuple[bool, str]:
"""Send notification to a specific provider."""
# Check quiet hours
if self._is_in_quiet_hours(provider):
logger.info(f"Skipping notification to {provider.name} - quiet hours active")
return True, "Skipped - quiet hours"
config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
try:
if provider.provider_type == "callmebot":
return await self._send_callmebot(config, f"{title}\n{message}")
elif provider.provider_type == "ntfy":
return await self._send_ntfy(config, title, message)
elif provider.provider_type == "pushover":
return await self._send_pushover(config, title, message)
elif provider.provider_type == "telegram":
return await self._send_telegram(config, f"*{title}*\n{message}")
elif provider.provider_type == "email":
return await self._send_email(config, title, message)
else:
return False, f"Unknown provider type: {provider.provider_type}"
except Exception as e:
logger.exception(f"Error sending notification via {provider.provider_type}")
return False, str(e)
async def _update_provider_status(
self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None
):
"""Update provider status after sending notification."""
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.id == provider_id)
)
provider = result.scalar_one_or_none()
if provider:
if success:
provider.last_success = datetime.utcnow()
else:
provider.last_error = error
provider.last_error_at = datetime.utcnow()
await db.commit()
async def _get_providers_for_event(
self,
db: AsyncSession,
event_field: str,
printer_id: int | None = None,
) -> list[NotificationProvider]:
"""Get all enabled providers that want a specific event type."""
# Build the query dynamically based on event field
query = select(NotificationProvider).where(
NotificationProvider.enabled == True,
getattr(NotificationProvider, event_field) == True,
)
if printer_id is not None:
query = query.where(
(NotificationProvider.printer_id == None) | (NotificationProvider.printer_id == printer_id)
)
result = await db.execute(query)
return list(result.scalars().all())
async def _send_to_providers(
self,
providers: list[NotificationProvider],
title: str,
message: str,
db: AsyncSession,
):
"""Send notification to multiple providers."""
for provider in providers:
try:
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)
if success:
logger.info(f"Sent notification via {provider.name}")
else:
logger.warning(f"Failed to send notification via {provider.name}: {error}")
except Exception as e:
logger.exception(f"Error sending notification via {provider.name}")
await self._update_provider_status(db, provider.id, False, str(e))
async def on_print_start(
self, printer_id: int, printer_name: str, data: dict, db: AsyncSession
):
"""Handle print start event - send notifications to relevant providers."""
logger.info(f"on_print_start called for printer {printer_id} ({printer_name})")
providers = await self._get_providers_for_event(db, "on_print_start", printer_id)
if not providers:
logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
return
logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
title, message = self._build_print_start_message(printer_name, data)
await self._send_to_providers(providers, title, message, db)
async def on_print_complete(
self,
printer_id: int,
printer_name: str,
status: str,
data: dict,
db: AsyncSession,
archive_data: dict | None = None,
):
"""Handle print complete event - send notifications to relevant providers."""
logger.info(f"on_print_complete called for printer {printer_id} ({printer_name}), status={status}")
# Determine which event type this is
if status == "completed":
event_field = "on_print_complete"
elif status in ("failed",):
event_field = "on_print_failed"
elif status in ("aborted", "stopped", "cancelled"):
event_field = "on_print_stopped"
else:
# Unknown status, default to on_print_complete
logger.warning(f"Unknown print status '{status}', defaulting to on_print_complete")
event_field = "on_print_complete"
providers = await self._get_providers_for_event(db, event_field, printer_id)
if not providers:
logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}")
return
logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
title, message = self._build_print_complete_message(printer_name, status, data, archive_data)
await self._send_to_providers(providers, title, message, db)
async def on_print_progress(
self,
printer_id: int,
printer_name: str,
filename: str,
progress: int,
db: AsyncSession,
):
"""Handle print progress milestone (25%, 50%, 75%)."""
providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
if not providers:
return
title, message = self._build_progress_message(printer_name, filename, progress)
await self._send_to_providers(providers, title, message, db)
async def on_printer_offline(
self, printer_id: int, printer_name: str, db: AsyncSession
):
"""Handle printer offline event."""
providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
if not providers:
return
title, message = self._build_printer_offline_message(printer_name)
await self._send_to_providers(providers, title, message, db)
async def on_printer_error(
self,
printer_id: int,
printer_name: str,
error_type: str,
db: AsyncSession,
error_detail: str | None = None,
):
"""Handle printer error event (AMS issues, etc.)."""
providers = await self._get_providers_for_event(db, "on_printer_error", printer_id)
if not providers:
return
title, message = self._build_printer_error_message(printer_name, error_type, error_detail)
await self._send_to_providers(providers, title, message, db)
async def on_filament_low(
self,
printer_id: int,
printer_name: str,
slot: int,
remaining_percent: int,
db: AsyncSession,
):
"""Handle low filament event."""
providers = await self._get_providers_for_event(db, "on_filament_low", printer_id)
if not providers:
return
title, message = self._build_filament_low_message(printer_name, slot, remaining_percent)
await self._send_to_providers(providers, title, message, db)
# Global instance
notification_service = NotificationService()
+146
View File
@@ -355,6 +355,129 @@ export interface KProfilesResponse {
nozzle_diameter: string;
}
// Notification Provider types
export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email';
export interface NotificationProvider {
id: number;
name: string;
provider_type: ProviderType;
enabled: boolean;
config: Record<string, unknown>;
// Print lifecycle events
on_print_start: boolean;
on_print_complete: boolean;
on_print_failed: boolean;
on_print_stopped: boolean;
on_print_progress: boolean;
// Printer status events
on_printer_offline: boolean;
on_printer_error: boolean;
on_filament_low: boolean;
// Quiet hours
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
// Printer filter
printer_id: number | null;
// Status tracking
last_success: string | null;
last_error: string | null;
last_error_at: string | null;
// Timestamps
created_at: string;
updated_at: string;
}
export interface NotificationProviderCreate {
name: string;
provider_type: ProviderType;
enabled?: boolean;
config: Record<string, unknown>;
// Print lifecycle events
on_print_start?: boolean;
on_print_complete?: boolean;
on_print_failed?: boolean;
on_print_stopped?: boolean;
on_print_progress?: boolean;
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
on_filament_low?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
quiet_hours_end?: string | null;
// Printer filter
printer_id?: number | null;
}
export interface NotificationProviderUpdate {
name?: string;
provider_type?: ProviderType;
enabled?: boolean;
config?: Record<string, unknown>;
// Print lifecycle events
on_print_start?: boolean;
on_print_complete?: boolean;
on_print_failed?: boolean;
on_print_stopped?: boolean;
on_print_progress?: boolean;
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
on_filament_low?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
quiet_hours_end?: string | null;
// Printer filter
printer_id?: number | null;
}
export interface NotificationTestRequest {
provider_type: ProviderType;
config: Record<string, unknown>;
}
export interface NotificationTestResponse {
success: boolean;
message: string;
}
// Provider-specific config types for reference
export interface CallMeBotConfig {
phone: string;
apikey: string;
}
export interface NtfyConfig {
server?: string;
topic: string;
auth_token?: string | null;
}
export interface PushoverConfig {
user_key: string;
app_token: string;
priority?: number;
}
export interface TelegramConfig {
bot_token: string;
chat_id: string;
}
export interface EmailConfig {
smtp_server: string;
smtp_port?: number;
username: string;
password: string;
from_email: string;
to_email: string;
use_tls?: boolean;
}
// API functions
export const api = {
// Printers
@@ -700,4 +823,27 @@ export const api = {
method: 'DELETE',
body: JSON.stringify(profile),
}),
// Notification Providers
getNotificationProviders: () => request<NotificationProvider[]>('/notifications/'),
getNotificationProvider: (id: number) => request<NotificationProvider>(`/notifications/${id}`),
createNotificationProvider: (data: NotificationProviderCreate) =>
request<NotificationProvider>('/notifications/', {
method: 'POST',
body: JSON.stringify(data),
}),
updateNotificationProvider: (id: number, data: NotificationProviderUpdate) =>
request<NotificationProvider>(`/notifications/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteNotificationProvider: (id: number) =>
request<{ message: string }>(`/notifications/${id}`, { method: 'DELETE' }),
testNotificationProvider: (id: number) =>
request<NotificationTestResponse>(`/notifications/${id}/test`, { method: 'POST' }),
testNotificationConfig: (data: NotificationTestRequest) =>
request<NotificationTestResponse>('/notifications/test-config', {
method: 'POST',
body: JSON.stringify(data),
}),
};
@@ -0,0 +1,414 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
import { api } from '../api/client';
import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
import { Button } from './Button';
interface AddNotificationModalProps {
provider?: NotificationProvider | null;
onClose: () => void;
}
const PROVIDER_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
{ value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
{ 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' },
];
export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
const queryClient = useQueryClient();
const isEditing = !!provider;
const [name, setName] = useState(provider?.name || '');
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'ntfy');
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');
// Provider-specific config
const [config, setConfig] = useState<Record<string, string>>(
provider?.config ? Object.fromEntries(Object.entries(provider.config).map(([k, v]) => [k, String(v)])) : {}
);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [error, setError] = useState<string | null>(null);
// Fetch printers for linking
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Test configuration mutation
const testMutation = useMutation({
mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config }),
onSuccess: (result) => {
setTestResult(result);
setError(null);
},
onError: (err: Error) => {
setTestResult({ success: false, message: err.message });
},
});
// Create mutation
const createMutation = useMutation({
mutationFn: (data: NotificationProviderCreate) => api.createNotificationProvider(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError('Name is required');
return;
}
// Validate provider-specific config
const requiredFields = getRequiredFields(providerType);
for (const field of requiredFields) {
if (!config[field.key]?.trim()) {
setError(`${field.label} is required`);
return;
}
}
const data = {
name: name.trim(),
provider_type: providerType,
config,
printer_id: printerId,
quiet_hours_enabled: quietHoursEnabled,
quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
};
if (isEditing) {
updateMutation.mutate(data);
} else {
createMutation.mutate(data);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
// Get config fields for each provider type
const getConfigFields = (type: ProviderType) => {
switch (type) {
case 'callmebot':
return [
{ key: 'phone', label: 'Phone Number', placeholder: '+1234567890', type: 'text', required: true },
{ key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'text', required: true },
];
case 'ntfy':
return [
{ key: 'server', label: 'Server URL', placeholder: 'https://ntfy.sh', type: 'text', required: false },
{ key: 'topic', label: 'Topic', placeholder: 'my-bambutrack', type: 'text', required: true },
{ key: 'auth_token', label: 'Auth Token', placeholder: 'Optional authentication', type: 'password', required: false },
];
case 'pushover':
return [
{ key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'text', required: true },
{ key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'text', required: true },
{ key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false },
];
case 'telegram':
return [
{ key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
{ key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },
];
case 'email':
return [
{ key: 'smtp_server', label: 'SMTP Server', placeholder: 'smtp.gmail.com', type: 'text', required: true },
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587', type: 'number', required: false },
{ key: 'security', label: 'Security', type: 'select', required: false, options: [
{ value: 'starttls', label: 'STARTTLS (Port 587)' },
{ value: 'ssl', label: 'SSL/TLS (Port 465)' },
{ value: 'none', label: 'None (Port 25)' },
]},
{ key: 'auth_enabled', label: 'Authentication', type: 'select', required: false, options: [
{ value: 'true', label: 'Enabled' },
{ value: 'false', label: 'Disabled' },
]},
{ key: 'username', label: 'Username', placeholder: 'your@email.com', type: 'text', required: false },
{ key: 'password', label: 'Password', placeholder: 'App password', type: 'password', required: false },
{ 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 },
];
default:
return [];
}
};
const getRequiredFields = (type: ProviderType) => {
return getConfigFields(type).filter(f => f.required);
};
const configFields = getConfigFields(providerType);
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<h2 className="text-lg font-semibold text-white">
{isEditing ? 'Edit Notification Provider' : 'Add Notification Provider'}
</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
{error}
</div>
)}
{/* Name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Notifications"
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>
{/* Provider Type */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Provider Type *</label>
<select
value={providerType}
onChange={(e) => {
setProviderType(e.target.value as ProviderType);
setConfig({}); // Reset config when changing type
setTestResult(null);
}}
disabled={isEditing}
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 disabled:opacity-50"
>
{PROVIDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
{PROVIDER_OPTIONS.find(o => o.value === providerType)?.description}
</p>
</div>
{/* Provider-specific configuration */}
<div className="space-y-3">
<p className="text-sm text-bambu-gray">Configuration</p>
{configFields.map((field) => (
<div key={field.key}>
<label className="block text-sm text-bambu-gray mb-1">
{field.label} {field.required && '*'}
</label>
{field.type === 'select' && field.options ? (
<select
value={config[field.key] || field.options[0]?.value || ''}
onChange={(e) => {
setConfig({ ...config, [field.key]: e.target.value });
setTestResult(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"
>
{field.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<input
type={field.type}
value={config[field.key] || ''}
onChange={(e) => {
setConfig({ ...config, [field.key]: e.target.value });
setTestResult(null);
}}
placeholder={field.placeholder}
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>
{/* Test Button */}
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={() => {
setTestResult(null);
testMutation.mutate();
}}
disabled={testMutation.isPending || !config[getRequiredFields(providerType)[0]?.key]}
className="flex-1"
>
{testMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Test Configuration
</Button>
</div>
{/* Test Result */}
{testResult && (
<div className={`p-3 rounded-lg flex items-center gap-2 ${
testResult.success
? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
: 'bg-red-500/20 border border-red-500/50 text-red-400'
}`}>
{testResult.success ? (
<>
<CheckCircle className="w-5 h-5" />
<span>{testResult.message}</span>
</>
) : (
<>
<XCircle className="w-5 h-5" />
<span>{testResult.message}</span>
</>
)}
</div>
)}
{/* Link to Printer */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Printer Filter</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(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="">All printers</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
Only send notifications for events from this printer
</p>
</div>
{/* Quiet Hours */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm text-white">Quiet Hours (Do Not Disturb)</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={quietHoursEnabled}
onChange={(e) => setQuietHoursEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{quietHoursEnabled && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-bambu-gray mb-1">Start</label>
<input
type="time"
value={quietHoursStart}
onChange={(e) => setQuietHoursStart(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-xs text-bambu-gray mb-1">End</label>
<input
type="time"
value={quietHoursEnd}
onChange={(e) => setQuietHoursEnd(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>
)}
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={isPending}
className="flex-1"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{isEditing ? 'Save' : 'Add'}
</Button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,408 @@
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 { api } from '../api/client';
import type { NotificationProvider, NotificationProviderUpdate } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
interface NotificationProviderCardProps {
provider: NotificationProvider;
onEdit: (provider: NotificationProvider) => void;
}
const PROVIDER_LABELS: Record<string, string> = {
callmebot: 'CallMeBot/WhatsApp',
ntfy: 'ntfy',
pushover: 'Pushover',
telegram: 'Telegram',
email: 'Email',
};
export function NotificationProviderCard({ provider, onEdit }: NotificationProviderCardProps) {
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
// Fetch printers for linking
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const linkedPrinter = printers?.find(p => p.id === provider.printer_id);
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: () => api.deleteNotificationProvider(provider.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
},
});
// Test mutation
const testMutation = useMutation({
mutationFn: () => api.testNotificationProvider(provider.id),
onSuccess: (result) => {
setTestResult(result);
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
},
onError: (err: Error) => {
setTestResult({ success: false, message: err.message });
},
});
// Format time for display
const formatTime = (time: string | null) => {
if (!time) return '';
return time;
};
return (
<>
<Card className="relative">
<CardContent className="p-4">
{/* Header Row */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${provider.enabled ? 'bg-bambu-green/20' : 'bg-bambu-dark'}`}>
<Bell className={`w-5 h-5 ${provider.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
</div>
<div>
<h3 className="font-medium text-white">{provider.name}</h3>
<p className="text-sm text-bambu-gray">{PROVIDER_LABELS[provider.provider_type] || provider.provider_type}</p>
</div>
</div>
{/* Status indicator */}
<div className="flex items-center gap-2">
{provider.last_success && (
<span className="text-xs text-bambu-green">Last sent: {new Date(provider.last_success).toLocaleDateString()}</span>
)}
{provider.last_error && (
<span className="text-xs text-red-400" title={provider.last_error}>Error</span>
)}
</div>
</div>
{/* Linked Printer */}
{linkedPrinter && (
<div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
<span className="text-xs text-bambu-gray">Printer: </span>
<span className="text-sm text-white">{linkedPrinter.name}</span>
</div>
)}
{!linkedPrinter && !provider.printer_id && (
<div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
<span className="text-xs text-bambu-gray">All printers</span>
</div>
)}
{/* Event summary - show all event tags */}
<div className="mb-3 flex flex-wrap gap-1">
{provider.on_print_start && (
<span className="px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded">Start</span>
)}
{provider.on_print_complete && (
<span className="px-2 py-0.5 bg-bambu-green/20 text-bambu-green text-xs rounded">Complete</span>
)}
{provider.on_print_failed && (
<span className="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded">Failed</span>
)}
{provider.on_print_stopped && (
<span className="px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">Stopped</span>
)}
{provider.on_print_progress && (
<span className="px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded">Progress</span>
)}
{provider.on_printer_offline && (
<span className="px-2 py-0.5 bg-gray-500/20 text-gray-400 text-xs rounded">Offline</span>
)}
{provider.on_printer_error && (
<span className="px-2 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">Error</span>
)}
{provider.on_filament_low && (
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-400 text-xs rounded">Low Filament</span>
)}
{provider.quiet_hours_enabled && (
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded flex items-center gap-1">
<Moon className="w-3 h-3" />
Quiet
</span>
)}
</div>
{/* Test Button */}
<div className="mb-3">
<Button
size="sm"
variant="secondary"
disabled={testMutation.isPending}
onClick={() => {
setTestResult(null);
testMutation.mutate();
}}
className="w-full"
>
{testMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Send Test Notification
</Button>
</div>
{/* Test Result */}
{testResult && (
<div className={`mb-3 p-2 rounded-lg flex items-center gap-2 text-sm ${
testResult.success
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-red-500/20 text-red-400'
}`}>
{testResult.success ? (
<CheckCircle className="w-4 h-4" />
) : (
<XCircle className="w-4 h-4" />
)}
<span>{testResult.message}</span>
</div>
)}
{/* Toggle Settings Panel */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors border-t border-bambu-dark-tertiary"
>
<span className="flex items-center gap-2">
<Settings2 className="w-4 h-4" />
Event Settings
</span>
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{/* Expanded Settings */}
{isExpanded && (
<div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
{/* Enabled Toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Enabled</p>
<p className="text-xs text-bambu-gray">Send notifications from this provider</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.enabled}
onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{/* Print Lifecycle Events */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">Print Events</p>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Started</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_start}
onChange={(e) => updateMutation.mutate({ on_print_start: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Completed</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_complete}
onChange={(e) => updateMutation.mutate({ on_print_complete: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Failed</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_failed}
onChange={(e) => updateMutation.mutate({ on_print_failed: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Stopped</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_stopped}
onChange={(e) => updateMutation.mutate({ on_print_stopped: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Progress Milestones</p>
<p className="text-xs text-bambu-gray">Notify at 25%, 50%, 75%</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_progress}
onChange={(e) => updateMutation.mutate({ on_print_progress: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</div>
{/* Printer Status Events */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">Printer Status</p>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Printer Offline</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_printer_offline}
onChange={(e) => updateMutation.mutate({ on_printer_offline: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Printer Error</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_printer_error}
onChange={(e) => updateMutation.mutate({ on_printer_error: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Low Filament</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_filament_low}
onChange={(e) => updateMutation.mutate({ on_filament_low: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
</div>
{/* Quiet Hours */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Moon className="w-4 h-4 text-purple-400" />
<p className="text-sm text-white">Quiet Hours</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.quiet_hours_enabled}
onChange={(e) => updateMutation.mutate({ quiet_hours_enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{provider.quiet_hours_enabled && (
<div className="pl-4 border-l-2 border-bambu-dark-tertiary space-y-2">
<p className="text-xs text-bambu-gray">No notifications during these hours</p>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-gray" />
<span className="text-sm text-white">
{formatTime(provider.quiet_hours_start) || '22:00'} - {formatTime(provider.quiet_hours_end) || '07:00'}
</span>
</div>
<p className="text-xs text-bambu-gray">Edit provider to change quiet hours</p>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<Button
size="sm"
variant="secondary"
onClick={() => onEdit(provider)}
className="flex-1"
>
<Edit2 className="w-4 h-4" />
Edit
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => setShowDeleteConfirm(true)}
className="text-red-400 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Delete Confirmation */}
{showDeleteConfirm && (
<ConfirmModal
title="Delete Notification Provider"
message={`Are you sure you want to delete "${provider.name}"? This cannot be undone.`}
confirmText="Delete"
variant="danger"
onConfirm={() => {
deleteMutation.mutate();
setShowDeleteConfirm(false);
}}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</>
);
}
+76 -3
View File
@@ -1,11 +1,13 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw } from 'lucide-react';
import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell } from 'lucide-react';
import { api } from '../api/client';
import type { AppSettings, SmartPlug } from '../api/client';
import type { AppSettings, SmartPlug, NotificationProvider } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
import { NotificationProviderCard } from '../components/NotificationProviderCard';
import { AddNotificationModal } from '../components/AddNotificationModal';
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
import { useState, useEffect } from 'react';
@@ -16,6 +18,8 @@ export function SettingsPage() {
const [showSaved, setShowSaved] = useState(false);
const [showPlugModal, setShowPlugModal] = useState(false);
const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
const [showNotificationModal, setShowNotificationModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
const handleDefaultViewChange = (path: string) => {
@@ -38,6 +42,11 @@ export function SettingsPage() {
queryFn: api.getSmartPlugs,
});
const { data: notificationProviders, isLoading: providersLoading } = useQuery({
queryKey: ['notification-providers'],
queryFn: api.getNotificationProviders,
});
const { data: ffmpegStatus } = useQuery({
queryKey: ['ffmpeg-status'],
queryFn: api.checkFfmpeg,
@@ -335,7 +344,7 @@ export function SettingsPage() {
</Card>
</div>
{/* Right Column - Smart Plugs */}
{/* Middle Column - Smart Plugs */}
<div className="w-96 flex-shrink-0">
<Card>
<CardHeader>
@@ -387,6 +396,59 @@ export function SettingsPage() {
</CardContent>
</Card>
</div>
{/* Right Column - Notifications */}
<div className="w-96 flex-shrink-0">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Notifications</h2>
</div>
<Button
size="sm"
onClick={() => {
setEditingProvider(null);
setShowNotificationModal(true);
}}
>
<Plus className="w-4 h-4" />
Add
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-bambu-gray mb-4">
Get notified about print events via WhatsApp, Telegram, Email, and more.
</p>
{providersLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : notificationProviders && notificationProviders.length > 0 ? (
<div className="space-y-4">
{notificationProviders.map((provider) => (
<NotificationProviderCard
key={provider.id}
provider={provider}
onEdit={(p) => {
setEditingProvider(p);
setShowNotificationModal(true);
}}
/>
))}
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No notification providers configured</p>
<p className="text-sm mt-1">Add a provider to get started</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Smart Plug Modal */}
@@ -399,6 +461,17 @@ export function SettingsPage() {
}}
/>
)}
{/* Notification Modal */}
{showNotificationModal && (
<AddNotificationModal
provider={editingProvider}
onClose={() => {
setShowNotificationModal(false);
setEditingProvider(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
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-C2qF9pCa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B9G6rzsh.css">
<script type="module" crossorigin src="/assets/index-0UQnyYo8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJNRCg8M.css">
</head>
<body>
<div id="root"></div>