diff --git a/DATABASE_SETUP.md b/DATABASE_SETUP.md new file mode 100644 index 0000000..0109c4f --- /dev/null +++ b/DATABASE_SETUP.md @@ -0,0 +1,198 @@ +# Database Setup and Health Monitoring Guide + +This application supports multiple database backends with automatic driver detection, optimization, and comprehensive health monitoring. + +## Supported Databases + +### SQLite (Default) +```bash +# Default - no configuration needed +DATABASE_URL=sqlite:///subscriptions.db +``` + +### PostgreSQL (with psycopg3) +```bash +# Standard PostgreSQL URL - automatically converted to use psycopg3 +DATABASE_URL=postgresql://username:password@hostname:port/database_name +DATABASE_URL=postgres://username:password@hostname:port/database_name + +# Explicit psycopg3 driver (also supported) +DATABASE_URL=postgresql+psycopg://username:password@hostname:port/database_name +``` + +### MySQL/MariaDB +```bash +DATABASE_URL=mysql+pymysql://username:password@hostname:port/database_name +DATABASE_URL=mariadb+pymysql://username:password@hostname:port/database_name +``` + +## PostgreSQL with psycopg3 Features + +The application automatically: +- Converts `postgresql://` and `postgres://` URLs to use psycopg3 driver +- Optimizes connection pooling for psycopg3 +- Sets appropriate timeouts and connection limits +- Enables connection health checks (`pool_pre_ping`) + +## Connection Pool Settings + +### PostgreSQL (psycopg3) +- **Pool Size**: 10 connections +- **Max Overflow**: 20 additional connections +- **Pool Timeout**: 30 seconds +- **Connection Timeout**: 10 seconds +- **Pool Recycle**: 1 hour (prevents stale connections) + +### MySQL/MariaDB +- **Pool Size**: 10 connections +- **Max Overflow**: 20 additional connections +- **Charset**: utf8mb4 (full Unicode support) + +### SQLite +- **Pool Timeout**: 10 seconds +- **Connection Timeout**: 20 seconds +- **Thread Safety**: Enabled (`check_same_thread=False`) + +## Health Monitoring + +### Docker Health Checks + +The application includes comprehensive Docker health checks: + +#### Built-in Health Endpoint +- **URL**: `http://localhost:5000/health` +- **Returns**: JSON with database connectivity and service status +- **Timeout**: 10 seconds +- **Retry**: 3 attempts + +#### Docker Health Check Configuration +```dockerfile +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:5000/health || exit 1 +``` + +#### Docker Compose Health Checks +```yaml +services: + web: + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + restart: unless-stopped + + postgres: # If using PostgreSQL + healthcheck: + test: ["CMD-SHELL", "pg_isready -U user -d database"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + mariadb: # If using MariaDB + healthcheck: + test: ["CMD", "/usr/local/bin/healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s +``` + +### Advanced Health Check Script + +Use the included `health-check.sh` script for detailed monitoring: + +```bash +# Basic health check +./health-check.sh + +# Detailed health check with JSON output +./health-check.sh --detailed --json + +# Custom timeout and URL +./health-check.sh --timeout=60 --url=http://your-domain.com + +# Help +./health-check.sh --help +``` + +### Health Check Response Format + +**Healthy Response** (200 OK): +```json +{ + "status": "healthy", + "database": "ok", + "currency_rates": "ok", + "timestamp": "2025-10-06T10:30:00.000000" +} +``` + +**Unhealthy Response** (500 Internal Server Error): +```json +{ + "status": "unhealthy", + "error": "Health check failed", + "timestamp": "2025-10-06T10:30:00.000000" +} +``` + +### Monitoring Integration + +The health checks work with: +- **Docker Swarm**: Service health monitoring +- **Kubernetes**: Liveness and readiness probes +- **Load Balancers**: Health check endpoints +- **Monitoring Tools**: Prometheus, Grafana, etc. + +### Health Check Status Meanings + +- **healthy**: All systems operational +- **unhealthy**: Critical failure (database connectivity issues) +- **degraded**: Some features may be limited (e.g., currency conversion) + +## Troubleshooting + +### "No module named 'psycopg2'" Error +This application uses **psycopg3** (modern PostgreSQL driver), not psycopg2. The error occurs when: +1. Using a plain `postgresql://` URL without driver specification +2. Solution: The app automatically converts URLs to use psycopg3 + +### Connection Issues +1. Verify database server is running and accessible +2. Check firewall settings for database port +3. Ensure database user has proper permissions +4. Review Docker logs for detailed error messages +5. Use health check endpoint to diagnose issues + +### Health Check Failures +```bash +# Check container health status +docker ps + +# View health check logs +docker inspect container_name | grep -A 10 Health + +# Manual health check +curl -f http://localhost:5000/health +``` + +## Docker Environment Variables + +```yaml +# docker-compose.yml example +environment: + - DATABASE_URL=postgresql://myuser:mypass@db:5432/subscriptions + - SECRET_KEY=your-secret-key-here +``` + +## Database Migration + +The application automatically: +- Creates tables on first run +- Applies schema migrations for new features +- Creates default admin user (username: `admin`, password: `changeme`) + +**Important**: Change the default admin password immediately after first login! \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 00751cc..a130a2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,7 @@ RUN apt-get update \ gosu \ default-mysql-client \ libpq5 \ + curl \ && rm -rf /var/lib/apt/lists/* # Copy installed packages from builder stage @@ -44,6 +45,10 @@ ENV FLASK_APP=run.py \ PUID=1000 \ PGID=1000 +# Health check configuration +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:5000/health || exit 1 + EXPOSE 5000 ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/TIMEOUT_FIX.md b/TIMEOUT_FIX.md deleted file mode 100644 index 593f73a..0000000 --- a/TIMEOUT_FIX.md +++ /dev/null @@ -1,179 +0,0 @@ -# Gunicorn Worker Timeout Fix - -## Problem Description - -The Subscription Tracker was experiencing Gunicorn worker timeout errors when saving subscriptions, leading to "Server Unavailable" errors in the browser. The error traceback showed: - -``` -File "/usr/local/lib/python3.13/site-packages/gunicorn/workers/base.py", line 204, in handle_abort - sys.exit(1) -``` - -## Root Causes Identified - -1. **Long-running external API calls** for currency conversion during subscription save operations -2. **Multiple synchronous API calls** to exchange rate providers without proper timeout handling -3. **No circuit breaker pattern** for failed API providers -4. **Database operations without timeout protection** -5. **Insufficient error handling** in critical paths - -## Fixes Implemented - -### 1. Improved Error Handling in Routes (`app/routes.py`) - -- Added try-catch blocks around subscription add/edit operations -- Added database rollback on errors -- Added user-friendly error messages -- Added logging for debugging - -```python -try: - # subscription save logic - db.session.commit() - flash('Subscription added successfully!', 'success') - return redirect(url_for('main.dashboard')) -except Exception as e: - db.session.rollback() - current_app.logger.error(f"Error adding subscription: {e}") - flash('An error occurred while saving the subscription. Please try again.', 'error') - return render_template('add_subscription.html', form=form) -``` - -### 2. Reduced API Timeouts (`app/currency.py`) - -- Reduced external API timeouts from 10s to 5s -- Added circuit breaker pattern for failed providers -- Improved fallback rate handling - -```python -def _fetch_frankfurter(self): - url = 'https://api.frankfurter.app/latest?from=EUR' - r = requests.get(url, timeout=5) # Reduced from 10s -``` - -### 3. Circuit Breaker Pattern (`app/currency.py`) - -- Added failure tracking for each provider -- Automatic circuit opening after 3 consecutive failures -- Circuit reset after 5 minutes - -```python -def _is_circuit_open(self, provider): - if provider not in self._circuit_breaker: - return False - failures, last_failure = self._circuit_breaker[provider] - # Reset circuit breaker after 5 minutes - if datetime.now().timestamp() - last_failure > 300: - del self._circuit_breaker[provider] - return False - # Open circuit after 3 consecutive failures - return failures >= 3 -``` - -### 4. Enhanced Gunicorn Configuration (`gunicorn.conf.py`) - -- Increased worker timeout from 30s to 60s -- Added proper worker management -- Enhanced logging configuration - -```python -# Worker timeout -timeout = 60 # Increased from default 30s -graceful_timeout = 30 -workers = 2 -worker_class = "sync" -``` - -### 5. Database Connection Improvements (`config.py`) - -- Added connection pool settings -- Added timeout configuration for SQLite -- Added connection pre-ping for health checks - -```python -SQLALCHEMY_ENGINE_OPTIONS = { - 'pool_timeout': 20, - 'pool_recycle': 3600, - 'pool_pre_ping': True, - 'connect_args': { - 'timeout': 30, - 'check_same_thread': False - } -} -``` - -### 6. Improved Currency Conversion Caching (`app/models.py`) - -- Enhanced caching strategy to avoid API calls during subscription operations -- Added fallback to database cache before making external API calls -- Better error handling in conversion methods - -### 7. Dashboard Performance Improvements (`app/routes.py`) - -- Pre-fetch exchange rates once per request -- Better error handling for cost calculations -- User-friendly warnings when rates are unavailable - -### 8. Application-level Error Handling (`app/__init__.py`) - -- Added global timeout error handler -- Added 500 error handler with proper rollback -- Added performance logging for slow requests - -### 9. Health Check Endpoint (`app/routes.py`) - -- Added `/health` endpoint for monitoring -- Checks database connectivity and currency rate availability - -### 10. Monitoring Script (`monitor.py`) - -- Python script to monitor application health -- Tests both health endpoint and functional operations -- Can be used for automated monitoring - -## Testing the Fixes - -1. **Basic Health Check**: - ```bash - curl http://localhost:5000/health - ``` - -2. **Monitor Application**: - ```bash - python monitor.py --url http://localhost:5000 --once - ``` - -3. **Load Testing**: - - Try saving multiple subscriptions quickly - - Test with different currencies - - Test when external APIs are slow/unavailable - -## Prevention Measures - -1. **Monitoring**: Use the health check endpoint for automated monitoring -2. **Alerting**: Set up alerts for 500 errors and slow response times -3. **Regular Testing**: Use the monitor script to test functionality -4. **Log Analysis**: Monitor application logs for warnings and errors - -## Recommended Environment Variables - -For production deployment, consider adding: - -```bash -# Reduce currency refresh frequency to avoid API rate limits -CURRENCY_REFRESH_MINUTES=1440 # 24 hours - -# Set specific provider priority -CURRENCY_PROVIDER_PRIORITY=frankfurter,floatrates,erapi_open - -# Enable performance logging -PERFORMANCE_LOGGING=true -``` - -## Expected Improvements - -- Reduced timeout errors by 90%+ -- Faster subscription save operations -- Better user experience with error messages -- More resilient currency conversion -- Easier debugging and monitoring diff --git a/app/forms.py b/app/forms.py index dcd0c8b..5906a3a 100644 --- a/app/forms.py +++ b/app/forms.py @@ -178,3 +178,13 @@ def validate_custom_headers(self, field): def validate_url(self, field): if not (field.data.startswith('http://') or field.data.startswith('https://')): raise ValidationError('URL must start with http:// or https://') + + # Enhanced validation using webhook module + try: + from app.webhooks import validate_webhook_url + validation_result = validate_webhook_url(self.webhook_type.data, field.data) + if not validation_result['valid']: + raise ValidationError(validation_result['message']) + except ImportError: + # Fallback to basic validation if webhook module isn't available + pass diff --git a/app/webhooks.py b/app/webhooks.py new file mode 100644 index 0000000..c125c49 --- /dev/null +++ b/app/webhooks.py @@ -0,0 +1,485 @@ +""" +Webhook notification system for Subscription Tracker + +Supports multiple webhook types: +- Discord +- Slack +- Microsoft Teams +- Gotify +- Generic webhooks + +This module handles sending notifications about expiring subscriptions +and testing webhook configurations. +""" + +import requests +import json +import logging +from datetime import datetime +from typing import Dict, List, Optional, Any +from flask import current_app + +# Set up logging +logger = logging.getLogger(__name__) + + +class WebhookSender: + """Base class for webhook senders""" + + def __init__(self, webhook): + self.webhook = webhook + self.timeout = 30 # seconds + + def send(self, message: str, title: str = None, color: str = None) -> Dict[str, Any]: + """ + Send a webhook message + + Args: + message: The message content + title: Optional title/subject + color: Optional color (hex code or name) + + Returns: + Dict with 'success' (bool) and 'message' (str) keys + """ + try: + payload = self.prepare_payload(message, title, color) + headers = self.webhook.get_auth_headers() + + logger.info(f"Sending {self.webhook.webhook_type} webhook to {self.webhook.name}") + + response = requests.post( + self.webhook.url, + json=payload, + headers=headers, + timeout=self.timeout + ) + + response.raise_for_status() + + # Update last_used timestamp + self.webhook.last_used = datetime.utcnow() + from app import db + db.session.commit() + + logger.info(f"Successfully sent {self.webhook.webhook_type} webhook") + return { + 'success': True, + 'message': f'Webhook sent successfully to {self.webhook.name}', + 'status_code': response.status_code + } + + except requests.exceptions.Timeout: + error_msg = f'Webhook request to {self.webhook.name} timed out after {self.timeout} seconds' + logger.error(error_msg) + return {'success': False, 'message': error_msg} + + except requests.exceptions.ConnectionError: + error_msg = f'Failed to connect to webhook {self.webhook.name}' + logger.error(error_msg) + return {'success': False, 'message': error_msg} + + except requests.exceptions.HTTPError as e: + error_msg = f'Webhook {self.webhook.name} returned HTTP error: {e.response.status_code}' + logger.error(f"{error_msg} - Response: {e.response.text[:200]}") + return {'success': False, 'message': error_msg} + + except Exception as e: + error_msg = f'Unexpected error sending webhook to {self.webhook.name}: {str(e)}' + logger.error(error_msg, exc_info=True) + return {'success': False, 'message': error_msg} + + def prepare_payload(self, message: str, title: str = None, color: str = None) -> Dict[str, Any]: + """Prepare the webhook payload - override in subclasses""" + return {"text": message} + + +class DiscordWebhookSender(WebhookSender): + """Discord webhook sender""" + + def prepare_payload(self, message: str, title: str = None, color: str = None) -> Dict[str, Any]: + # Convert color name to Discord color integer + color_map = { + 'red': 0xFF0000, + 'orange': 0xFF8C00, + 'yellow': 0xFFFF00, + 'green': 0x00FF00, + 'blue': 0x0000FF, + 'purple': 0x800080, + 'pink': 0xFFC0CB + } + + embed = { + "description": message, + "timestamp": datetime.utcnow().isoformat(), + "footer": { + "text": "Subscription Tracker" + } + } + + if title: + embed["title"] = title + + if color: + if isinstance(color, str): + if color.startswith('#'): + # Convert hex color to integer + embed["color"] = int(color[1:], 16) + elif color.lower() in color_map: + embed["color"] = color_map[color.lower()] + elif isinstance(color, int): + embed["color"] = color + else: + embed["color"] = 0x7289DA # Discord blurple + + return { + "embeds": [embed] + } + + +class SlackWebhookSender(WebhookSender): + """Slack webhook sender""" + + def prepare_payload(self, message: str, title: str = None, color: str = None) -> Dict[str, Any]: + # Slack color mapping + color_map = { + 'red': 'danger', + 'orange': 'warning', + 'yellow': 'warning', + 'green': 'good', + } + + attachment = { + "text": message, + "ts": int(datetime.utcnow().timestamp()), + "footer": "Subscription Tracker" + } + + if title: + attachment["title"] = title + + if color: + if color.lower() in color_map: + attachment["color"] = color_map[color.lower()] + elif color in ['good', 'warning', 'danger']: + attachment["color"] = color + else: + attachment["color"] = color # Custom hex color + + return { + "attachments": [attachment] + } + + +class TeamsWebhookSender(WebhookSender): + """Microsoft Teams webhook sender""" + + def prepare_payload(self, message: str, title: str = None, color: str = None) -> Dict[str, Any]: + # Teams theme color mapping + color_map = { + 'red': 'FF0000', + 'orange': 'FF8C00', + 'yellow': 'FFD700', + 'green': '00FF00', + 'blue': '0078D4', # Microsoft blue + 'purple': '800080' + } + + card = { + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "summary": title or "Subscription Tracker Notification", + "text": message, + "potentialAction": [] + } + + if title: + card["title"] = title + + if color: + theme_color = color + if color.lower() in color_map: + theme_color = color_map[color.lower()] + elif color.startswith('#'): + theme_color = color[1:] # Remove # for Teams + card["themeColor"] = theme_color + + return card + + +class GotifyWebhookSender(WebhookSender): + """Gotify webhook sender""" + + def prepare_payload(self, message: str, title: str = None, color: str = None) -> Dict[str, Any]: + # Gotify priority mapping + priority_map = { + 'red': 8, # High priority + 'orange': 6, # Medium-high priority + 'yellow': 4, # Medium priority + 'green': 2, # Low priority + 'blue': 2, # Low priority + } + + payload = { + "message": message, + "priority": priority_map.get(color, 4) if color else 4 + } + + if title: + payload["title"] = title + else: + payload["title"] = "Subscription Tracker" + + return payload + + +class GenericWebhookSender(WebhookSender): + """Generic webhook sender for custom webhook formats""" + + def prepare_payload(self, message: str, title: str = None, color: str = None) -> Dict[str, Any]: + payload = { + "text": message, + "timestamp": datetime.utcnow().isoformat() + } + + if title: + payload["title"] = title + + if color: + payload["color"] = color + + return payload + + +def get_webhook_sender(webhook) -> WebhookSender: + """Factory function to get the appropriate webhook sender""" + sender_map = { + 'discord': DiscordWebhookSender, + 'slack': SlackWebhookSender, + 'teams': TeamsWebhookSender, + 'gotify': GotifyWebhookSender, + 'generic': GenericWebhookSender + } + + sender_class = sender_map.get(webhook.webhook_type, GenericWebhookSender) + return sender_class(webhook) + + +def send_test_webhook(app, webhook, user) -> Dict[str, Any]: + """ + Send a test webhook message + + Args: + app: Flask application instance + webhook: Webhook model instance + user: User model instance + + Returns: + Dict with 'success' (bool) and 'message' (str) keys + """ + if not webhook.is_active: + return { + 'success': False, + 'message': f'Webhook "{webhook.name}" is disabled' + } + + with app.app_context(): + try: + sender = get_webhook_sender(webhook) + + test_message = ( + f"๐Ÿงช **Test Webhook from Subscription Tracker**\n\n" + f"Hello {user.username}!\n\n" + f"This is a test message to verify your {webhook.webhook_type} webhook configuration.\n\n" + f"**Webhook Details:**\n" + f"โ€ข Name: {webhook.name}\n" + f"โ€ข Type: {webhook.webhook_type.title()}\n" + f"โ€ข Test Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" + f"If you received this message, your webhook is working correctly! ๐ŸŽ‰" + ) + + result = sender.send( + message=test_message, + title="๐Ÿงช Webhook Test - Subscription Tracker", + color="blue" + ) + + logger.info(f"Test webhook sent to {webhook.name} for user {user.username}: {result}") + return result + + except Exception as e: + error_msg = f'Failed to send test webhook: {str(e)}' + logger.error(error_msg, exc_info=True) + return {'success': False, 'message': error_msg} + + +def send_all_webhook_notifications(app, user, expiring_subscriptions) -> int: + """ + Send webhook notifications for expiring subscriptions + + Args: + app: Flask application instance + user: User model instance + expiring_subscriptions: List of expiring subscriptions + + Returns: + int: Number of webhooks sent successfully + """ + if not expiring_subscriptions: + return 0 + + with app.app_context(): + try: + from app.models import Webhook + + # Get active webhooks for the user + webhooks = Webhook.query.filter_by(user_id=user.id, is_active=True).all() + + if not webhooks: + logger.info(f"No active webhooks configured for user {user.username}") + return 0 + + successful_sends = 0 + + # Prepare the notification message + message_parts = [ + f"๐Ÿ”” **Subscription Expiry Notification**", + f"", + f"Hello {user.username}!", + f"", + f"You have {len(expiring_subscriptions)} subscription(s) expiring soon:", + f"" + ] + + total_cost = 0 + user_currency = user.settings.currency if user.settings else 'EUR' + + for sub in expiring_subscriptions: + days_left = sub.days_until_expiry() + + # Convert cost to user's preferred currency + cost_display = sub.get_cost_in_currency(user_currency) + + if days_left is not None: + if days_left == 0: + status = "โš ๏ธ **EXPIRES TODAY**" + elif days_left == 1: + status = "โš ๏ธ **Expires tomorrow**" + else: + status = f"๐Ÿ“… Expires in {days_left} days" + else: + status = "๐Ÿ“… Check expiry date" + + message_parts.append( + f"โ€ข **{sub.name}** by {sub.company} - " + f"{cost_display:.2f} {user_currency}/{sub.billing_cycle} - {status}" + ) + + total_cost += cost_display + + if total_cost > 0: + message_parts.extend([ + f"", + f"๐Ÿ’ฐ **Total expiring cost:** {total_cost:.2f} {user_currency}", + ]) + + message_parts.extend([ + f"", + f"Don't forget to review and renew your subscriptions as needed!", + f"", + f"Manage your subscriptions: [Dashboard Link]" + ]) + + notification_message = "\n".join(message_parts) + + # Send to all active webhooks + for webhook in webhooks: + try: + sender = get_webhook_sender(webhook) + + # Determine color based on urgency + urgency_color = "red" # Default to urgent + min_days = min([sub.days_until_expiry() or 0 for sub in expiring_subscriptions]) + + if min_days == 0: + urgency_color = "red" # Expires today + elif min_days <= 1: + urgency_color = "orange" # Expires very soon + elif min_days <= 3: + urgency_color = "yellow" # Expires soon + else: + urgency_color = "blue" # Advance notice + + result = sender.send( + message=notification_message, + title=f"๐Ÿ”” {len(expiring_subscriptions)} Subscription(s) Expiring Soon", + color=urgency_color + ) + + if result['success']: + successful_sends += 1 + logger.info(f"Notification webhook sent to {webhook.name} for user {user.username}") + else: + logger.error(f"Failed to send notification webhook to {webhook.name}: {result['message']}") + + except Exception as e: + logger.error(f"Error sending webhook to {webhook.name}: {str(e)}", exc_info=True) + + logger.info(f"Sent {successful_sends}/{len(webhooks)} webhook notifications for user {user.username}") + return successful_sends + + except Exception as e: + logger.error(f"Failed to send webhook notifications for user {user.username}: {str(e)}", exc_info=True) + return 0 + + +def validate_webhook_url(webhook_type: str, url: str) -> Dict[str, Any]: + """ + Validate a webhook URL format for a specific webhook type + + Args: + webhook_type: Type of webhook (discord, slack, teams, gotify, generic) + url: The webhook URL to validate + + Returns: + Dict with 'valid' (bool) and 'message' (str) keys + """ + if not url or not url.strip(): + return {'valid': False, 'message': 'URL cannot be empty'} + + url = url.strip() + + # Basic URL validation + if not url.startswith(('http://', 'https://')): + return {'valid': False, 'message': 'URL must start with http:// or https://'} + + # Type-specific validation + if webhook_type == 'discord': + if 'discord.com' not in url or '/webhooks/' not in url: + return {'valid': False, 'message': 'Discord webhook URL must contain discord.com and /webhooks/'} + + elif webhook_type == 'slack': + if 'hooks.slack.com' not in url: + return {'valid': False, 'message': 'Slack webhook URL must contain hooks.slack.com'} + + elif webhook_type == 'teams': + if 'outlook.office.com' not in url and 'outlook.office365.com' not in url: + return {'valid': False, 'message': 'Teams webhook URL must be an Office 365 connector URL'} + + # URL appears valid + return {'valid': True, 'message': 'URL format appears valid'} + + +# For backwards compatibility and ease of import +__all__ = [ + 'send_test_webhook', + 'send_all_webhook_notifications', + 'validate_webhook_url', + 'get_webhook_sender', + 'WebhookSender', + 'DiscordWebhookSender', + 'SlackWebhookSender', + 'TeamsWebhookSender', + 'GotifyWebhookSender', + 'GenericWebhookSender' +] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b78f6cd..f883c9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,4 +22,11 @@ services: - CURRENCY_PROVIDER_PRIORITY=${CURRENCY_PROVIDER_PRIORITY:-frankfurter,floatrates,erapi_open} - PERFORMANCE_LOGGING=${PERFORMANCE_LOGGING:-true} volumes: - - YOURDATAFOLDER:/app/instance + - YOURDATAVOLUME:/app/instance + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + restart: unless-stopped