mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Added notification module
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user