diff --git a/changedetectionio/blueprint/notification_dashboard/__init__.py b/changedetectionio/blueprint/notification_dashboard/__init__.py new file mode 100644 index 000000000..223e498de --- /dev/null +++ b/changedetectionio/blueprint/notification_dashboard/__init__.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +""" +Notification Dashboard Blueprint +Handles the notification queue dashboard UI and related functionality +""" + +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify +from changedetectionio.flask_app import login_optionally_required + + +def construct_blueprint(): + """Construct and return the notification dashboard blueprint""" + notification_dashboard = Blueprint('notification_dashboard', __name__, template_folder='templates') + + @notification_dashboard.route("/", methods=['GET']) + @login_optionally_required + def dashboard(): + """Notification queue dashboard - shows pending, retrying, and failed notifications""" + from changedetectionio.notification.task_queue import ( + get_pending_notifications, + get_failed_notifications, + get_retry_config, + get_last_successful_notification + ) + + # Get pending/retrying notifications + pending_list = get_pending_notifications(limit=1000) + pending_count = len(pending_list) if pending_list else 0 + + # Get failed (dead letter) notifications + failed_notifications = get_failed_notifications() + + # Get retry configuration for display + retry_config = get_retry_config() + + # Get last successful notification for reference + last_success = get_last_successful_notification() + + return render_template( + 'notification-dashboard.html', + pending_list=pending_list, + pending_count=pending_count, + failed_notifications=failed_notifications, + retry_config=retry_config, + last_success=last_success + ) + + @notification_dashboard.route("/log/", methods=['GET']) + @login_optionally_required + def get_notification_log(task_id): + """Get Apprise log for a specific notification task""" + from changedetectionio.notification.task_queue import get_task_apprise_log + + log_data = get_task_apprise_log(task_id) + + if log_data: + return jsonify(log_data) + else: + return jsonify({'error': 'Log not found for this task'}), 404 + + @notification_dashboard.route("/send-now/", methods=['GET']) + @login_optionally_required + def send_now(task_id): + """Execute a scheduled notification immediately""" + from changedetectionio.notification.task_queue import execute_scheduled_notification + + success = execute_scheduled_notification(task_id) + if success: + message = "✓ Notification sent successfully and removed from queue." + flash(message, 'notice') + else: + message = "Failed to send notification. It remains scheduled for automatic retry." + flash(message, 'error') + + return redirect(url_for('notification_dashboard.dashboard')) + + @notification_dashboard.route("/retry/", methods=['POST']) + @login_optionally_required + def retry_notification(task_id): + """Retry a failed notification (from dead letter queue)""" + from changedetectionio.notification.task_queue import retry_failed_notification + + success = retry_failed_notification(task_id) + message = f"Notification queued for retry." if success else f"Failed to retry notification. Check logs for details." + + if success: + flash(message, 'notice') + else: + flash(message, 'error') + + return redirect(url_for('notification_dashboard.dashboard')) + + @notification_dashboard.route("/retry-all", methods=['POST']) + @login_optionally_required + def retry_all_notifications(): + """Retry all failed notifications""" + from changedetectionio.notification.task_queue import retry_all_failed_notifications + + result = retry_all_failed_notifications() + + if result['total'] == 0: + flash("No failed notifications to retry.", 'notice') + elif result['failed'] == 0: + flash(f"Successfully queued {result['success']} notification(s) for retry.", 'notice') + else: + flash(f"Queued {result['success']} notification(s) for retry. {result['failed']} failed to queue.", 'error') + + return redirect(url_for('notification_dashboard.dashboard')) + + @notification_dashboard.route("/clear-all", methods=['POST']) + @login_optionally_required + def clear_all_notifications(): + """Clear ALL notifications (pending, retrying, and failed)""" + from changedetectionio.notification.task_queue import clear_all_notifications + + result = clear_all_notifications() + + if 'error' in result: + flash(f"Error clearing notifications: {result['error']}", 'error') + else: + total_cleared = result.get('queue', 0) + result.get('schedule', 0) + result.get('results', 0) + flash(f"Cleared {total_cleared} notification(s) from queue.", 'notice') + + return redirect(url_for('notification_dashboard.dashboard')) + + return notification_dashboard diff --git a/changedetectionio/blueprint/notification_dashboard/templates/notification-dashboard.html b/changedetectionio/blueprint/notification_dashboard/templates/notification-dashboard.html new file mode 100644 index 000000000..21d929517 --- /dev/null +++ b/changedetectionio/blueprint/notification_dashboard/templates/notification-dashboard.html @@ -0,0 +1,254 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+ +

Notification Queue Dashboard

+ + + {% if last_success %} +
+
✅ Most Recent Successful Notification
+
+
+ Timestamp: {{ last_success.timestamp_formatted }} +
+ {% if last_success.watch_url %} + + {% endif %} + {% if last_success.notification_urls %} +
+ Sent via: + {% for url in last_success.notification_urls %} + {{ url }} + {% endfor %} +
+ {% endif %} + {% if last_success.apprise_logs %} +
+ 📋 View Apprise Logs +
{% for log_line in last_success.apprise_logs %}{{ log_line }}
+{% endfor %}
+
+ {% endif %} +
+

+ Use this as reference - this notification was sent successfully with current settings. +

+
+ {% endif %} + + + {% if (pending_count and pending_count > 0) or failed_notifications|length > 0 %} +
+ {% if failed_notifications|length > 0 %} +
+ + +
+ {% endif %} +
+ + +
+
+ {% endif %} + + +
+ + +
+
+
🔄 Pending / Retrying
+
{{ pending_count if pending_count is not none else '?' }}
+
+

Notifications currently queued or being retried

+ + {% if pending_list %} +
+ {% for item in pending_list %} +
+
+ + {% if item.status == 'queued' %}QUEUED{% else %}RETRYING{% if item.retry_number is defined and item.total_retries is defined %} ({{ item.retry_number }}/{{ item.total_retries }}){% endif %}{% endif %} + + {% if item.watch_uuid %} + Watch: {{ item.watch_uuid[:8] }}... + {% endif %} +
+ {% if item.task_id %} +
ID: {{ item.task_id[:20] }}...
+ {% endif %} + {% if item.queued_at_formatted %} +
Queued: {{ item.queued_at_formatted }}
+ {% endif %} + {% if item.watch_url %} +
Target: {{ item.watch_url }}
+ {% endif %} + {% if item.status == 'retrying' %} +
+ + ⏰ Next retry: {{ item.retry_at_formatted }} + {% if item.retry_in_seconds > 0 %} + (in {{ item.retry_in_seconds }}s) + {% endif %} + + {% if item.task_id %} + Send Now + {% endif %} +
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
✅ No pending notifications
+ {% endif %} +
+ + +
+
+
💀 Failed (Dead Letter)
+
+ {{ failed_notifications|length }} +
+
+

Exhausted all retry attempts

+ + {% if failed_notifications|length > 0 %} +
+ {% for notification in failed_notifications %} +
+
+ FAILED + {% if notification.notification_data and notification.notification_data.get('uuid') %} + + Watch: {{ notification.notification_data.get('uuid')[:8] }}... + + {% endif %} +
+ {% if notification.task_id %} +
ID: {{ notification.task_id[:20] }}...
+ {% endif %} + {% if notification.notification_data and notification.notification_data.get('watch_url') %} +
Target: {{ notification.notification_data.get('watch_url') }}
+ {% endif %} + {% if notification.notification_data and notification.notification_data.get('notification_urls') %} +
+ Notification endpoints: + {% for url in notification.notification_data.get('notification_urls') %} +
• {{ url }}
+ {% endfor %} +
+ {% endif %} + {% if notification.timestamp %} +
+ Failed: {{ notification.timestamp_formatted }} + {% if notification.days_ago is defined %} + ({{ notification.days_ago }} day{{ 's' if notification.days_ago != 1 else '' }} ago) + {% endif %} +
+ {% endif %} +
+ + +
+
+ {% endfor %} +
+ {% else %} +
✅ No failed notifications
+ {% endif %} +
+
+ + + + + +
+
Automatic Retry Schedule (Exponential Backoff)
+

+ Notifications are automatically retried {{ retry_config.retry_count }} times with exponential backoff starting at {{ retry_config.retry_delay_seconds }} seconds. +

+ + + + + + + + + + + + + + + {% for i in range(retry_config.retry_count) %} + {% set delay = retry_config.retry_delays[i] %} + {% set cumulative_time = retry_config.retry_delays[:i+1]|sum %} + + + + + + {% endfor %} + + + + + + +
AttemptTimeAction
1st (initial)T+0:00⚠️ Fails (e.g., SMTP server down)
{{ i + 2 }}{{ ['st', 'nd', 'rd'][i + 1] if i + 1 < 3 else 'th' }} (retry {{ i + 1 }})T+{{ '%d:%02d' % (cumulative_time // 60, cumulative_time % 60) }}{% if i < retry_config.retry_count - 1 %}⚠️ Fails → Wait {{ delay }}s ({{ '%d:%02d' % (delay // 60, delay % 60) }}){% else %}⚠️ Fails → Give up{% endif %}
Dead LetterT+{{ '%d:%02d' % (retry_config.total_time_seconds // 60, retry_config.total_time_seconds % 60) }}💀 Moved to this failed notifications list
+

+ Total: {{ retry_config.total_attempts }} attempts over {{ '%d:%02d' % (retry_config.total_time_seconds // 60, retry_config.total_time_seconds % 60) }} (mm:ss). + Failed notifications are kept for 30 days, then automatically deleted. +

+
+ + + + + + +
+
+ +{% endblock %} diff --git a/changedetectionio/blueprint/settings/__init__.py b/changedetectionio/blueprint/settings/__init__.py index b1c0e4b81..7c847aae4 100644 --- a/changedetectionio/blueprint/settings/__init__.py +++ b/changedetectionio/blueprint/settings/__init__.py @@ -4,7 +4,7 @@ from datetime import datetime from zoneinfo import ZoneInfo, available_timezones import secrets import flask_login -from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from changedetectionio.store import ChangeDetectionStore from changedetectionio.auth_decorator import login_optionally_required @@ -142,73 +142,42 @@ def construct_blueprint(datastore: ChangeDetectionStore): logs=notification_debug_log if len(notification_debug_log) else ["Notification logs are empty - no notifications sent yet."]) return output + # Legacy routes - redirect to new notification dashboard blueprint @settings_blueprint.route("/failed-notifications", methods=['GET']) @login_optionally_required def failed_notifications(): - """View notifications that failed all retry attempts""" - from changedetectionio.notification.task_queue import get_failed_notifications, get_retry_config, get_pending_notifications_count, get_last_successful_notification, get_pending_notifications + """Redirect to new notification dashboard""" + return redirect(url_for('notification_dashboard.dashboard')) - failed = get_failed_notifications(limit=100) - retry_config = get_retry_config() - pending_count = get_pending_notifications_count() - pending_list = get_pending_notifications(limit=50) - last_success = get_last_successful_notification() - - output = render_template("failed-notifications.html", - failed_notifications=failed, - retry_config=retry_config, - pending_count=pending_count, - pending_list=pending_list, - last_success=last_success) - return output + @settings_blueprint.route("/notification-log/", methods=['GET']) + @login_optionally_required + def get_notification_log(task_id): + """Redirect to new notification dashboard log endpoint""" + return redirect(url_for('notification_dashboard.get_notification_log', task_id=task_id)) @settings_blueprint.route("/retry-notification/", methods=['POST']) @login_optionally_required def retry_notification(task_id): - """Retry a failed notification by task ID""" - from changedetectionio.notification.task_queue import retry_failed_notification + """Redirect to new notification dashboard retry endpoint""" + return redirect(url_for('notification_dashboard.retry_notification', task_id=task_id), code=307) - success = retry_failed_notification(task_id) - - if success: - flash(f"Notification {task_id} queued for retry.", 'notice') - else: - flash(f"Failed to retry notification {task_id}. Check logs for details.", 'error') - - return redirect(url_for('settings.failed_notifications')) + @settings_blueprint.route("/send-now/", methods=['GET']) + @login_optionally_required + def send_now(task_id): + """Redirect to new notification dashboard send now endpoint""" + return redirect(url_for('notification_dashboard.send_now', task_id=task_id)) @settings_blueprint.route("/retry-all-notifications", methods=['POST']) @login_optionally_required def retry_all_notifications(): - """Retry all failed notifications""" - from changedetectionio.notification.task_queue import retry_all_failed_notifications - - result = retry_all_failed_notifications() - - if result['total'] == 0: - flash("No failed notifications to retry.", 'notice') - elif result['failed'] == 0: - flash(f"Successfully queued {result['success']} notification(s) for retry.", 'notice') - else: - flash(f"Queued {result['success']} notification(s) for retry. {result['failed']} failed to queue.", 'error') - - return redirect(url_for('settings.failed_notifications')) + """Redirect to new notification dashboard retry all endpoint""" + return redirect(url_for('notification_dashboard.retry_all_notifications'), code=307) @settings_blueprint.route("/clear-all-notifications", methods=['POST']) @login_optionally_required def clear_all_notifications(): - """Clear ALL notifications (queue, schedule, results, retry attempts)""" - from changedetectionio.notification.task_queue import clear_all_notifications as clear_all - - result = clear_all() - - if 'error' in result: - flash(f"Error clearing notifications: {result['error']}", 'error') - else: - total = result['queue'] + result['schedule'] + result['results'] + result['retry_attempts'] + result.get('task_metadata', 0) - flash(f"Cleared {total} notification(s): {result['queue']} queued, {result['schedule']} scheduled, {result['results']} failed, {result['retry_attempts']} retry attempts, {result.get('task_metadata', 0)} task metadata.", 'notice') - - return redirect(url_for('settings.failed_notifications')) + """Redirect to new notification dashboard clear all endpoint""" + return redirect(url_for('notification_dashboard.clear_all_notifications'), code=307) @settings_blueprint.route("/api/v1/notifications/failed", methods=['GET']) @login_optionally_required diff --git a/changedetectionio/blueprint/settings/templates/failed-notifications.html b/changedetectionio/blueprint/settings/templates/failed-notifications.html index 4326232fa..4667d054f 100644 --- a/changedetectionio/blueprint/settings/templates/failed-notifications.html +++ b/changedetectionio/blueprint/settings/templates/failed-notifications.html @@ -2,114 +2,212 @@ {% block content %}
-
+
-

Failed Notifications (Exhausted Retries)

+

Notification Queue Dashboard

{% if last_success %} -
-
✅ Most Recent Successful Notification
-
-
+
+
✅ Most Recent Successful Notification
+
+
Timestamp: {{ last_success.timestamp_formatted }}
{% if last_success.watch_url %} -
+ {% endif %} {% if last_success.notification_urls %} -
+
Sent via: {% for url in last_success.notification_urls %} - {{ url }} + {{ url }} {% endfor %}
{% endif %} {% if last_success.apprise_logs %} -
- 📋 View Apprise Logs -
{% for log_line in last_success.apprise_logs %}{{ log_line }}
+                
+ 📋 View Apprise Logs +
{% for log_line in last_success.apprise_logs %}{{ log_line }}
 {% endfor %}
{% endif %}
-

+

Use this as reference - this notification was sent successfully with current settings.

{% endif %} - -
-
Notification Queue Status
-
-
- {% if pending_count is not none %} -
- 🔄 Pending/Retrying: - {{ pending_count }} - notification{{ 's' if pending_count != 1 else '' }} -
-

- Currently in queue or being retried -

- {% else %} -
- 🔄 Pending/Retrying: Unable to determine -
- {% endif %} -
-
-
- 💀 Failed (Dead Letter): - {{ failed_notifications|length }} - notification{{ 's' if failed_notifications|length != 1 else '' }} -
-

- Exhausted all retry attempts -

-
-
+ + {% if (pending_count and pending_count > 0) or failed_notifications|length > 0 %} +
+ {% if failed_notifications|length > 0 %} +
+ + +
+ {% endif %} +
+ + +
+
+ {% endif %} - - {% if pending_list %} -
- 📋 View Pending/Retrying Notifications ({{ pending_list|length }}) -
+ +
+ + +
+
+
🔄 Pending / Retrying
+
{{ pending_count if pending_count is not none else '?' }}
+
+

Notifications currently queued or being retried

+ + {% if pending_list %} +
{% for item in pending_list %} -
-
- {% if item.status == 'queued' %}⏳ Queued{% else %}🔄 Retrying{% endif %}: - {% if item.watch_url %} - {{ item.watch_url }} - {% else %} - Test notification +
+
+ + {% if item.status == 'queued' %}QUEUED{% else %}RETRYING{% if item.retry_number is defined and item.total_retries is defined %} ({{ item.retry_number }}/{{ item.total_retries }}){% endif %}{% endif %} + + {% if item.watch_uuid %} + Watch: {{ item.watch_uuid[:8] }}... {% endif %}
- {% if item.status == 'retrying' and item.retry_at_formatted %} -
- Retry at: {{ item.retry_at_formatted }} - {% if item.retry_in_seconds > 0 %} - (in {{ item.retry_in_seconds }}s) + {% if item.task_id %} +
ID: {{ item.task_id[:20] }}...
+ {% endif %} + {% if item.queued_at_formatted %} +
Queued: {{ item.queued_at_formatted }}
+ {% endif %} + {% if item.watch_url %} +
Target: {{ item.watch_url }}
+ {% endif %} + {% if item.status == 'retrying' %} +
+ + ⏰ Next retry: {{ item.retry_at_formatted }} + {% if item.retry_in_seconds > 0 %} + (in {{ item.retry_in_seconds }}s) + {% endif %} + + {% if item.task_id %} + Send Now {% endif %}
{% endif %}
{% endfor %}
-
- {% endif %} + {% else %} +
✅ No pending notifications
+ {% endif %} +
+ + +
+
+
💀 Failed (Dead Letter)
+
+ {{ failed_notifications|length }} +
+
+

Exhausted all retry attempts

+ + {% if failed_notifications|length > 0 %} +
+ {% for notification in failed_notifications %} +
+
+ FAILED + {% if notification.notification_data and notification.notification_data.get('uuid') %} + + Watch: {{ notification.notification_data.get('uuid')[:8] }}... + + {% endif %} +
+ {% if notification.task_id %} +
ID: {{ notification.task_id[:20] }}...
+ {% endif %} + {% if notification.notification_data and notification.notification_data.get('watch_url') %} +
Target: {{ notification.notification_data.get('watch_url') }}
+ {% endif %} + {% if notification.notification_data and notification.notification_data.get('notification_urls') %} +
+ Notification endpoints: + {% for url in notification.notification_data.get('notification_urls') %} +
• {{ url }}
+ {% endfor %} +
+ {% endif %} + {% if notification.timestamp %} +
+ Failed: {{ notification.timestamp_formatted }} + {% if notification.days_ago is defined %} + ({{ notification.days_ago }} day{{ 's' if notification.days_ago != 1 else '' }} ago) + {% endif %} +
+ {% endif %} +
+ + +
+
+ {% endfor %} +
+ {% else %} +
✅ No failed notifications
+ {% endif %} +
+
+ + + -
-
Automatic Retry Schedule (Exponential Backoff)
-

+

+
Automatic Retry Schedule (Exponential Backoff)
+

Notifications are automatically retried {{ retry_config.retry_count }} times with exponential backoff starting at {{ retry_config.retry_delay_seconds }} seconds.

- +
@@ -139,121 +237,16 @@
Attempt
-

+

Total: {{ retry_config.total_attempts }} attempts over {{ '%d:%02d' % (retry_config.total_time_seconds // 60, retry_config.total_time_seconds % 60) }} (mm:ss). Failed notifications are kept for 30 days, then automatically deleted.

- -
- {% if failed_notifications|length > 0 %} -

- These notifications failed after all {{ retry_config.retry_count }} retry attempts. - You can fix the notification settings (e.g., SMTP server) and retry them manually below. -

-
- - -
- {% endif %} + + - - {% if pending_count > 0 or failed_notifications|length > 0 %} -
- -
- {% endif %} - - {% if failed_notifications|length > 0 or pending_count > 0 %} -
- {% if failed_notifications|length > 0 %} - Retry All: Re-queue failed notifications with current settings.
- {% endif %} - Clear All: Delete ALL pending, retrying, and failed notifications (cannot be undone). -
- {% endif %} -
- - {% if failed_notifications|length == 0 %} -
-

- ✅ No failed notifications - All notifications either succeeded or are still being retried. -

-
- -
- {% for notification in failed_notifications %} -
- {% if notification.timestamp %} -
- ⚠️ Failed: {{ notification.timestamp_formatted }} - {% if notification.days_ago is defined %} - ({{ notification.days_ago }} day{{ 's' if notification.days_ago != 1 else '' }} ago) - {% endif %} -
- {% endif %} - -
- Task ID: {{ notification.task_id }} -
- - {% if notification.notification_data and notification.notification_data.get('watch_url') %} - - {% endif %} - - {% if notification.notification_data and notification.notification_data.get('uuid') %} -
- Watch UUID: {{ notification.notification_data.get('uuid') }} -
- {% endif %} - - {% if notification.retry_attempts %} -
- Retry Attempts: -
- {% for attempt in notification.retry_attempts %} -
- - Attempt #{{ attempt.attempt_number }} - {{ attempt.timestamp_formatted }} - {% if attempt.will_retry %} - → Will retry - {% else %} - → Final attempt - {% endif %} - -
-
{{ attempt.error }}
-
-
- {% endfor %} -
-
- {% else %} -
- Error: -
{{ notification.error }}
-
- {% endif %} - -
- -
-
- {% endfor %} -
- {% endif %} +
diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index 877eb8a0e..e5056373b 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -527,6 +527,9 @@ def changedetection_app(config=None, datastore_o=None): import changedetectionio.blueprint.settings as settings app.register_blueprint(settings.construct_blueprint(datastore), url_prefix='/settings') + import changedetectionio.blueprint.notification_dashboard as notification_dashboard + app.register_blueprint(notification_dashboard.construct_blueprint(), url_prefix='/notification-dashboard') + import changedetectionio.conditions.blueprint as conditions app.register_blueprint(conditions.construct_blueprint(datastore), url_prefix='/conditions') diff --git a/changedetectionio/notification/task_queue/__init__.py b/changedetectionio/notification/task_queue/__init__.py index 3cdd5b01b..403e0d3f9 100644 --- a/changedetectionio/notification/task_queue/__init__.py +++ b/changedetectionio/notification/task_queue/__init__.py @@ -12,8 +12,7 @@ Environment Variables: """ import os -import struct -import time + from loguru import logger # Get queue storage type from environment @@ -208,9 +207,12 @@ def init_huey(datastore_path): ) # Configure Huey's logger to only show INFO and above (reduce scheduler DEBUG spam) + # Don't do this when running under pytest - tests may want to see DEBUG logs import logging - huey_logger = logging.getLogger('huey') - huey_logger.setLevel(logging.INFO) + import sys + if 'pytest' not in sys.modules: + huey_logger = logging.getLogger('huey') + huey_logger.setLevel(logging.INFO) return huey @@ -279,111 +281,152 @@ def get_pending_notifications(limit=50): return [] pending = [] - import os import pickle import time try: - storage_type = type(huey.storage).__name__ + # Use Huey's built-in methods to get queued and scheduled items + # These methods return pickled bytes that need to be unpickled - if storage_type == 'FileStorage' and hasattr(huey.storage, 'path'): - # FileStorage: Read pickled task files - storage_path = huey.storage.path + # Get queued tasks (immediate execution) + if hasattr(huey.storage, 'enqueued_items'): + try: + queued_items = list(huey.storage.enqueued_items(limit=limit)) + for queued_bytes in queued_items: + if len(pending) >= limit: + break + try: + message = pickle.loads(queued_bytes) + if hasattr(message, 'args') and message.args: + notification_data = message.args[0] + # Get task ID and metadata for timestamp + task_id = message.id if hasattr(message, 'id') else None + metadata = _get_task_metadata(task_id) if task_id else None + queued_timestamp = metadata.get('timestamp') if metadata else None - # Get queued tasks (immediate) - queue_dir = os.path.join(storage_path, 'queue') - if os.path.exists(queue_dir): - for root, dirs, files in os.walk(queue_dir): - for filename in files: - if filename.startswith('.') or len(pending) >= limit: - continue - filepath = os.path.join(root, filename) - try: - with open(filepath, 'rb') as f: - task_data = pickle.load(f) - notification_data = task_data.get('args', [{}])[0] if task_data.get('args') else {} - pending.append({ - 'status': 'queued', - 'watch_url': notification_data.get('watch_url', 'Unknown'), - 'watch_uuid': notification_data.get('uuid'), - 'queued_at': task_data.get('execute_time'), - }) - except Exception: - pass + # Format timestamp for display + from changedetectionio.notification_service import timestamp_to_localtime + queued_at_formatted = timestamp_to_localtime(queued_timestamp) if queued_timestamp else 'Unknown' - # Get scheduled tasks (retrying) - schedule_dir = os.path.join(storage_path, 'schedule') - if os.path.exists(schedule_dir): - for root, dirs, files in os.walk(schedule_dir): - for filename in files: - if filename.startswith('.') or len(pending) >= limit: - continue - filepath = os.path.join(root, filename) - try: - with open(filepath, 'rb') as f: - task_data = pickle.load(f) - notification_data = task_data.get('args', [{}])[0] if task_data.get('args') else {} - eta = task_data.get('eta') - pending.append({ - 'status': 'retrying', - 'watch_url': notification_data.get('watch_url', 'Unknown'), - 'watch_uuid': notification_data.get('uuid'), - 'retry_at': eta, - 'retry_in_seconds': int(eta - time.time()) if eta else 0, - }) - except Exception: - pass + pending.append({ + 'status': 'queued', + 'watch_url': notification_data.get('watch_url', 'Unknown'), + 'watch_uuid': notification_data.get('uuid'), + 'task_id': task_id, + 'queued_at': queued_timestamp, + 'queued_at_formatted': queued_at_formatted, + }) + except Exception as e: + logger.debug(f"Error processing queued item: {e}") + continue + except Exception as e: + logger.debug(f"Error getting queued items: {e}") - elif storage_type in ['SqliteStorage', 'SqliteHuey'] and hasattr(huey.storage, 'filename'): - # SqliteStorage: Query database - import sqlite3 - conn = sqlite3.connect(huey.storage.filename) - cursor = conn.cursor() + # Get scheduled tasks (retrying) + if hasattr(huey.storage, 'scheduled_items'): + try: + scheduled_items = list(huey.storage.scheduled_items(limit=limit)) + for scheduled_bytes in scheduled_items: + if len(pending) >= limit: + break + try: + message = pickle.loads(scheduled_bytes) + if hasattr(message, 'args') and message.args: + notification_data = message.args[0] + eta = message.eta if hasattr(message, 'eta') else None + # Calculate seconds until retry (eta is a datetime object) + import datetime + if eta: + now = datetime.datetime.now() if eta.tzinfo is None else datetime.datetime.now(datetime.timezone.utc) + retry_in_seconds = int((eta - now).total_seconds()) - # Get queued tasks - cursor.execute("SELECT data FROM queue LIMIT ?", (limit,)) - for row in cursor.fetchall(): - try: - task_data = pickle.loads(row[0]) - notification_data = task_data.get('args', [{}])[0] if task_data.get('args') else {} - pending.append({ - 'status': 'queued', - 'watch_url': notification_data.get('watch_url', 'Unknown'), - 'watch_uuid': notification_data.get('uuid'), - }) - except Exception: - pass + # Convert eta to local timezone for display + if eta.tzinfo is not None: + local_tz = datetime.datetime.now().astimezone().tzinfo + eta_local = eta.astimezone(local_tz) + eta_formatted = eta_local.strftime('%Y-%m-%d %H:%M:%S %Z') + else: + eta_formatted = eta.strftime('%Y-%m-%d %H:%M:%S') + else: + retry_in_seconds = 0 + eta_formatted = 'Unknown' - # Get scheduled tasks - cursor.execute("SELECT data, eta FROM schedule LIMIT ?", (limit - len(pending),)) - for row in cursor.fetchall(): - try: - task_data = pickle.loads(row[0]) - notification_data = task_data.get('args', [{}])[0] if task_data.get('args') else {} - eta = row[1] - pending.append({ - 'status': 'retrying', - 'watch_url': notification_data.get('watch_url', 'Unknown'), - 'watch_uuid': notification_data.get('uuid'), - 'retry_at': eta, - 'retry_in_seconds': int(eta - time.time()) if eta else 0, - }) - except Exception: - pass + # Get task ID for manual retry button + task_id = message.id if hasattr(message, 'id') else None - conn.close() + # Convert eta to Unix timestamp for JavaScript (with safety check) + retry_at_timestamp = None + if eta and hasattr(eta, 'timestamp'): + try: + # Huey stores ETA as naive datetime in UTC - need to add timezone info + if eta.tzinfo is None: + # Naive datetime - assume it's UTC (Huey's default) + import datetime + eta = eta.replace(tzinfo=datetime.timezone.utc) + retry_at_timestamp = int(eta.timestamp()) + logger.debug(f"ETA after timezone fix: {eta}, Timestamp: {retry_at_timestamp}") + except Exception as e: + logger.debug(f"Error converting eta to timestamp: {e}") - # Format timestamps for display - from changedetectionio.notification_service import timestamp_to_localtime - for item in pending: - if item.get('queued_at'): - item['queued_at_formatted'] = timestamp_to_localtime(item['queued_at']) - if item.get('retry_at'): - item['retry_at_formatted'] = timestamp_to_localtime(item['retry_at']) + # Get original queued timestamp from metadata + metadata = _get_task_metadata(task_id) if task_id else None + queued_timestamp = metadata.get('timestamp') if metadata else None + + # Format timestamp for display + from changedetectionio.notification_service import timestamp_to_localtime + queued_at_formatted = timestamp_to_localtime(queued_timestamp) if queued_timestamp else 'Unknown' + + # Get retry count from retry_attempts directory + # If there are N attempt files, the next attempt will be N+1 + retry_number = 1 # Default to 1 (first retry after initial failure) + total_retries = NOTIFICATION_RETRY_COUNT + watch_uuid = notification_data.get('uuid') + if watch_uuid and huey and hasattr(huey.storage, 'path'): + try: + import os + attempts_dir = os.path.join(huey.storage.path, 'retry_attempts') + if os.path.exists(attempts_dir): + attempt_files = [f for f in os.listdir(attempts_dir) if f.startswith(f"{watch_uuid}.")] + if len(attempt_files) > 0: + # Next attempt number = number of previous attempts + 1 + retry_number = len(attempt_files) + 1 + logger.debug(f"Watch {watch_uuid[:8]}: Found {len(attempt_files)} retry files, next attempt will be #{retry_number}/{total_retries}") + else: + # Directory exists but no files yet - first retry + retry_number = 1 + logger.debug(f"Watch {watch_uuid[:8]}: Retry attempts dir exists but empty, first retry (attempt #1/{total_retries})") + else: + # No attempts dir yet - this is first retry (after initial failure) + retry_number = 1 + logger.debug(f"Watch {watch_uuid[:8]}: No retry attempts dir, this is first retry (attempt #1/{total_retries})") + except Exception as e: + logger.warning(f"Error reading retry attempts for {watch_uuid}: {e}, defaulting to attempt #1") + retry_number = 1 # Fallback to 1 on error + + pending.append({ + 'status': 'retrying', + 'watch_url': notification_data.get('watch_url', 'Unknown'), + 'watch_uuid': notification_data.get('uuid'), + 'retry_at': eta, + 'retry_at_formatted': eta_formatted, + 'retry_at_timestamp': retry_at_timestamp, + 'retry_in_seconds': retry_in_seconds, + 'task_id': task_id, + 'queued_at': queued_timestamp, + 'queued_at_formatted': queued_at_formatted, + 'retry_number': retry_number, + 'total_retries': total_retries, + }) + except Exception as e: + logger.debug(f"Error processing scheduled item: {e}") + continue + except Exception as e: + logger.debug(f"Error getting scheduled items: {e}") except Exception as e: logger.error(f"Error getting pending notifications: {e}", exc_info=True) + logger.debug(f"get_pending_notifications returning {len(pending)} items") return pending @@ -471,6 +514,44 @@ def get_failed_notifications(limit=100, max_age_days=30): for task_id, result in results.items(): if isinstance(result, (Exception, HueyError)): # This is a failed task (either Exception or Huey Error object) + # Check if task is still scheduled for retry + # If it is, don't include it in failed list (still retrying) + if huey.storage: + try: + # Check if this task is in the schedule queue (still being retried) + task_still_scheduled = False + + # Use Huey's built-in scheduled_items() method to get scheduled tasks + try: + if hasattr(huey.storage, 'scheduled_items'): + import pickle + scheduled_items = list(huey.storage.scheduled_items()) + for scheduled_bytes in scheduled_items: + try: + # scheduled_items() returns pickled bytes, need to unpickle + scheduled_message = pickle.loads(scheduled_bytes) + # Each item is a Message object with an 'id' attribute + if hasattr(scheduled_message, 'id'): + scheduled_task_id = scheduled_message.id + if scheduled_task_id == task_id: + task_still_scheduled = True + logger.debug(f"Task {task_id[:20]}... IS scheduled") + break + except Exception as e: + logger.debug(f"Error checking scheduled message: {e}") + continue + except Exception as se: + logger.debug(f"Error checking schedule: {se}") + + # Skip this task if it's still scheduled for retry + if task_still_scheduled: + logger.debug(f"Task {task_id[:20]}... still scheduled for retry, not counting as failed yet") + continue + else: + logger.debug(f"Task {task_id[:20]}... NOT in schedule, counting as failed") + except Exception as e: + logger.debug(f"Error checking schedule for task {task_id}: {e}") + # Try to extract notification data from task metadata storage try: # Get task metadata from our metadata storage @@ -552,6 +633,179 @@ def _delete_result(task_id): return task_manager.delete_result(task_id) +def get_task_apprise_log(task_id): + """ + Get the Apprise log for a specific task. + + Returns dict with: + - apprise_log: str (the log text) + - task_id: str + - watch_url: str (if available) + - notification_urls: list (if available) + - error: str (if failed) + """ + if huey is None: + return None + + try: + # First check task metadata for notification data and logs + metadata = _get_task_metadata(task_id) + + # Also check Huey result for error info (failed tasks) + from huey.utils import Error as HueyError + error_info = None + try: + result = huey.result(task_id, preserve=True) + if result and isinstance(result, (Exception, HueyError)): + error_info = str(result) + except Exception as e: + # If huey.result() raises an exception, that IS the error we want + # (Huey raises the stored exception when calling result() on failed tasks) + error_info = str(e) + logger.debug(f"Got error from result for task {task_id}: {type(e).__name__}") + + if metadata: + # Get apprise logs from metadata (could be 'apprise_logs' list or 'apprise_log' string) + apprise_logs = metadata.get('apprise_logs', []) + apprise_log_text = '\n'.join(apprise_logs) if isinstance(apprise_logs, list) else metadata.get('apprise_log', '') + + # If no logs in metadata but we have error_info, try to extract from error + if not apprise_log_text and error_info and 'Apprise logs:' in error_info: + parts = error_info.split('Apprise logs:', 1) + if len(parts) > 1: + apprise_log_text = parts[1].strip() + # The exception string has escaped newlines (\n), convert to actual newlines + apprise_log_text = apprise_log_text.replace('\\n', '\n') + # Also remove trailing quotes and closing parens from exception repr + apprise_log_text = apprise_log_text.rstrip("')") + logger.debug(f"Extracted Apprise logs from error for task {task_id}: {len(apprise_log_text)} chars") + + # Clean up error to not duplicate the Apprise logs + # Only show the main error message, not the logs again + error_parts = error_info.split('\nApprise logs:', 1) + if len(error_parts) > 1: + error_info = error_parts[0] # Keep only the main error message + + # Use metadata for apprise_log and notification data, but also include error from result + result = { + 'task_id': task_id, + 'apprise_log': apprise_log_text if apprise_log_text else 'No log available', + 'watch_url': metadata.get('notification_data', {}).get('watch_url'), + 'notification_urls': metadata.get('notification_data', {}).get('notification_urls', []), + 'error': error_info if error_info else metadata.get('error'), + 'timestamp': metadata.get('timestamp') + } + logger.debug(f"Returning log data for task {task_id}: apprise_log length={len(result['apprise_log'])}, has_error={bool(result['error'])}") + return result + + # If not in metadata, try to extract from result only + if error_info: + # Try to extract Apprise log from error message + apprise_log = 'No detailed log available' + if 'Apprise logs:' in error_info: + parts = error_info.split('Apprise logs:', 1) + if len(parts) > 1: + apprise_log = parts[1].strip() + + return { + 'task_id': task_id, + 'apprise_log': apprise_log, + 'error': error_info + } + + return None + + except Exception as e: + logger.error(f"Error getting Apprise log for task {task_id}: {e}") + return None + + +def execute_scheduled_notification(task_id): + """ + Execute a scheduled/retrying notification immediately by canceling the schedule and queuing it now. + + Uses Huey's native revoke_by_id() to cancel the scheduled task, then immediately queues it. + + Args: + task_id: Huey task ID to execute immediately + + Returns: + True if successfully executed, False otherwise + """ + if huey is None: + logger.error("Huey not initialized") + return False + + try: + # First, check if task is actually scheduled + scheduled_items = list(huey.storage.scheduled_items()) + task_found = False + notification_data = None + + import pickle + for scheduled_bytes in scheduled_items: + try: + message = pickle.loads(scheduled_bytes) + if hasattr(message, 'id') and message.id == task_id: + task_found = True + # Extract notification data from scheduled task + if hasattr(message, 'args') and message.args: + notification_data = message.args[0] + break + except Exception as e: + logger.debug(f"Error checking scheduled task: {e}") + continue + + if not task_found: + logger.error(f"Task {task_id} not found in schedule") + return False + + if not notification_data: + logger.error(f"No notification data found for task {task_id}") + return False + + # Execute the notification NOW (synchronously, not queued) by calling the task function directly + logger.info(f"Executing notification for task {task_id} immediately (not queued)...") + + try: + # Import here to avoid circular dependency + from changedetectionio.flask_app import datastore + from changedetectionio.notification.handler import process_notification + from changedetectionio.notification_service import NotificationContextData + + # Wrap dict in NotificationContextData if needed + if not isinstance(notification_data, NotificationContextData): + notification_data = NotificationContextData(notification_data) + + # Call the notification processing function directly (not via Huey queue) + # This executes synchronously in the current thread + sent_obj = process_notification(notification_data, datastore) + + # If we get here, notification was sent successfully! + logger.info(f"✓ Notification sent successfully for task {task_id}") + + # NOW revoke the scheduled task since we successfully sent it + huey.revoke_by_id(task_id, revoke_once=True) + logger.info(f"Revoked scheduled task {task_id} (no longer needed)") + + # Clean up old metadata and result + _delete_result(task_id) + _delete_task_metadata(task_id) + logger.info(f"Cleaned up old result/metadata for task {task_id}") + + return True + + except Exception as e: + # Notification failed - keep the scheduled task so it retries later + logger.warning(f"Failed to send notification for task {task_id}: {e}") + logger.info(f"Keeping scheduled task {task_id} in queue for automatic retry") + return False + + except Exception as e: + logger.error(f"Error executing scheduled notification {task_id}: {e}") + return False + + def retry_failed_notification(task_id): """ Retry a failed notification by task ID. diff --git a/changedetectionio/notification_service.py b/changedetectionio/notification_service.py index 31512dd57..1e25d7365 100644 --- a/changedetectionio/notification_service.py +++ b/changedetectionio/notification_service.py @@ -118,7 +118,8 @@ class NotificationContextData(dict): def timestamp_to_localtime(timestamp): # Format the date using locale-aware formatting with timezone - dt = datetime.datetime.fromtimestamp(int(timestamp)) + # Unix timestamps are always UTC, so use utcfromtimestamp to avoid double conversion + dt = datetime.datetime.utcfromtimestamp(int(timestamp)) dt = dt.replace(tzinfo=pytz.UTC) # Get local timezone-aware datetime @@ -229,9 +230,9 @@ class NotificationService: timestamp_changed=dates[date_index_to])) # Queue notification to Huey for processing with retry logic - from changedetectionio.notification.task_queue import send_notification_task + from changedetectionio.notification.task_queue import queue_notification logger.debug("Queuing notification to Huey for sending with retry") - send_notification_task(dict(n_object)) + queue_notification(dict(n_object)) return n_object def send_content_changed_notification(self, watch_uuid): diff --git a/changedetectionio/static/js/notification-dashboard.js b/changedetectionio/static/js/notification-dashboard.js new file mode 100644 index 000000000..f47810ceb --- /dev/null +++ b/changedetectionio/static/js/notification-dashboard.js @@ -0,0 +1,59 @@ +/** + * Notification Dashboard - Interactive functionality + * Handles timezone conversion, AJAX log fetching, and user interactions + */ + +$(function() { + // Convert retry timestamps to local timezone + $('.retry-time[data-timestamp]').each(function() { + var timestamp = parseInt($(this).data('timestamp')); + if (timestamp) { + var formatted = new Intl.DateTimeFormat(undefined, { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + timeZoneName: 'short' + }).format(timestamp * 1000); + $(this).text(formatted); + } + }); + + // Handle notification card clicks to fetch and display logs + $('.notification-card').css('cursor', 'pointer').click(function(e) { + // Don't trigger if clicking on a button or form + if ($(e.target).is('button, input') || $(e.target).closest('form, button').length) return; + + var taskId = $(this).data('task-id'); + if (!taskId) return; + + // Show loading state + $('#last-log-info').show(); + $('#log-apprise-content').text('Loading...'); + + // Fetch log via AJAX + var logUrl = $('#log-url-template').data('url').replace('TASK_ID', taskId); + $.getJSON(logUrl) + .done(function(data) { + $('#log-task-id').text(data.task_id); + $('#log-watch-url').text(data.watch_url || '').parent().toggle(!!data.watch_url); + + if (data.notification_urls && data.notification_urls.length) { + $('#log-notification-urls').html(data.notification_urls.map(url => + '
• ' + url + '
').join('')); + $('#log-notification-urls-container').show(); + } else { + $('#log-notification-urls-container').hide(); + } + + $('#log-apprise-content').text(data.apprise_log || 'No log available'); + $('#log-error-content').text(data.error || ''); + $('#log-error-container').toggle(!!data.error); + + // Scroll to log + $('#last-log-info')[0].scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }) + .fail(function(xhr) { + var error = xhr.responseJSON && xhr.responseJSON.error ? xhr.responseJSON.error : 'Failed to load log'; + $('#log-apprise-content').text('Error: ' + error); + }); + }); +}); diff --git a/changedetectionio/static/styles/scss/parts/_notificationsdashboard.scss b/changedetectionio/static/styles/scss/parts/_notificationsdashboard.scss new file mode 100644 index 000000000..84c0ddf47 --- /dev/null +++ b/changedetectionio/static/styles/scss/parts/_notificationsdashboard.scss @@ -0,0 +1,600 @@ +// Notification Queue Dashboard Styles + +.notifications-dashboard { + h4 { + margin-top: 0; + } + + // Last successful notification reference box + .last-success-box { + background: #d4edda; + border: 1px solid #c3e6cb; + border-radius: 5px; + padding: 15px; + margin-bottom: 20px; + + h5 { + margin-top: 0; + color: #155724; + } + + .details { + font-size: 90%; + + > div { + margin-bottom: 5px; + } + + code { + background: #fff; + padding: 2px 6px; + border-radius: 3px; + margin-right: 5px; + font-size: 85%; + } + } + + details { + margin-top: 10px; + + summary { + cursor: pointer; + font-weight: bold; + font-size: 90%; + color: #155724; + } + + pre { + background: #fff; + padding: 10px; + border-radius: 3px; + border: 1px solid #c3e6cb; + margin: 10px 0 0 0; + white-space: pre-wrap; + word-wrap: break-word; + font-size: 80%; + color: #333; + max-height: 300px; + overflow-y: auto; + } + } + + .note { + font-size: 85%; + color: #155724; + margin: 10px 0 0 0; + font-style: italic; + } + } + + // Dashboard action buttons (outside grid) + .dashboard-actions { + margin-bottom: 20px; + display: flex; + gap: 10px; + flex-wrap: wrap; + + form { + display: inline-block; + margin: 0; + } + + .retry-all-btn { + background: #28a745; + font-size: 90%; + } + + .clear-all-btn { + background: #6c757d; + color: white; + font-size: 90%; + } + } + + // Dashboard grid layout + .dashboard-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 20px; + + @media (max-width: 768px) { + grid-template-columns: 1fr !important; + } + } + + // Column containers + .pending-column { + background: #f8f9fa; + border: 2px solid #0066cc; + border-radius: 8px; + padding: 20px; + } + + .failed-column { + background: #fff3f3; + border: 2px solid #dc3545; + border-radius: 8px; + padding: 20px; + } + + // Column headers + .column-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 15px; + + h5 { + margin: 0; + } + + .count-badge { + color: white; + padding: 4px 12px; + border-radius: 12px; + font-weight: bold; + font-size: 90%; + } + } + + .pending-column .column-header { + h5 { + color: #0066cc; + } + + .count-badge { + background: #0066cc; + } + } + + .failed-column .column-header { + h5 { + color: #dc3545; + } + + .count-badge { + &.has-failures { + background: #dc3545; + } + + &.no-failures { + background: #28a745; + } + } + } + + .column-description { + font-size: 85%; + color: #666; + margin: 0 0 15px 0; + } + + // Notification cards + .notification-cards { + max-height: 400px; + overflow-y: auto; + } + + .notification-card { + background: white; + border-radius: 5px; + padding: 12px; + margin-bottom: 10px; + + &.pending-card { + border: 1px solid #dee2e6; + } + + &.failed-card { + border: 1px solid #dc3545; + } + } + + .card-header { + display: flex; + align-items: center; + margin-bottom: 8px; + + .status-badge { + color: white; + padding: 2px 8px; + border-radius: 3px; + font-size: 75%; + font-weight: bold; + margin-right: 8px; + + &.queued { + background: #ffc107; + } + + &.retrying { + background: #0066cc; + } + + &.failed { + background: #dc3545; + } + } + + a { + font-weight: bold; + color: #333; + } + } + + .notification-id { + font-size: 75%; + color: #999; + margin-bottom: 5px; + font-family: monospace; + } + + .notification-queued-time { + font-size: 80%; + color: #999; + margin-bottom: 5px; + } + + .notification-target { + font-size: 85%; + color: #666; + margin-bottom: 5px; + word-break: break-all; + } + + .notification-endpoints { + font-size: 85%; + color: #666; + margin-bottom: 8px; + + .endpoint-item { + margin-left: 10px; + font-family: monospace; + font-size: 80%; + } + } + + .retry-info { + font-size: 80%; + color: #999; + display: flex; + align-items: center; + justify-content: space-between; + + form { + display: inline; + margin-left: 10px; + } + + .send-now-btn { + font-size: 75%; + padding: 2px 6px; + background: #17a2b8; + color: white; + } + } + + .failure-time { + font-size: 80%; + color: #999; + margin-bottom: 8px; + } + + .retry-form { + margin-top: 10px; + + button { + font-size: 85%; + padding: 5px 10px; + } + } + + // Log info box (AJAX notification details) + .log-info-box { + background: #f5f5f5; + border: 1px solid #ddd; + border-radius: 5px; + padding: 15px; + margin-bottom: 20px; + + .log-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + border-bottom: 1px solid #ddd; + padding-bottom: 10px; + + h5 { + margin: 0; + color: #333; + } + + .close-btn { + background: transparent; + border: none; + font-size: 20px; + cursor: pointer; + padding: 0; + width: 25px; + height: 25px; + line-height: 25px; + text-align: center; + color: #666; + + &:hover { + color: #000; + } + } + } + + .log-content { + .log-meta { + margin-bottom: 15px; + font-size: 90%; + + > div { + margin-bottom: 5px; + } + + strong { + color: #555; + } + + .notification-id { + font-family: monospace; + font-size: 85%; + color: #666; + } + + .endpoint-item { + margin-left: 10px; + font-family: monospace; + font-size: 85%; + } + } + + .log-body { + h6 { + margin: 10px 0 5px 0; + color: #555; + } + + pre { + background: #fff; + border: 1px solid #ddd; + border-radius: 3px; + padding: 10px; + font-size: 80%; + max-height: 400px; + overflow-y: auto; + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + color: #333; + } + } + + .log-error { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #ddd; + + h6 { + margin: 0 0 5px 0; + color: #dc3545; + } + + pre { + background: #fff3f3; + border: 1px solid #ffcdd2; + border-radius: 3px; + padding: 10px; + font-size: 80%; + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + color: #c62828; + } + } + } + } + + // Empty state + .empty-state { + background: white; + border-radius: 5px; + padding: 20px; + text-align: center; + + &.pending-empty { + border: 1px solid #dee2e6; + color: #999; + } + + &.failed-empty { + border: 1px solid #28a745; + color: #28a745; + } + } + + // Retry schedule table + .retry-schedule { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 5px; + padding: 15px; + margin-bottom: 20px; + + h5 { + margin-top: 0; + } + + p { + font-size: 90%; + margin-bottom: 10px; + } + + table { + width: 100%; + font-size: 90%; + } + + .note { + font-size: 85%; + margin-top: 10px; + margin-bottom: 0; + color: #666; + } + } + + // Dark mode support + @media (prefers-color-scheme: dark) { + background: #1e1e1e; + color: #e0e0e0; + + h4, h5 { + color: #e0e0e0; + } + + .last-success-box { + background: #1a3a2a; + border-color: #2d5a3d; + + h5 { + color: #66bb6a; + } + + .details code { + background: #2d2d2d; + color: #e0e0e0; + } + + details pre { + background: #2d2d2d; + border-color: #2d5a3d; + color: #e0e0e0; + } + + .note { + color: #81c784; + } + } + + .dashboard-grid { + .pending-column { + background: #252525; + border-color: #3a7bc8; + } + + .failed-column { + background: #2a2020; + border-color: #c84040; + } + } + + .notification-card { + background: #2d2d2d; + border-color: #444; + + .card-header a { + color: #90caf9; + } + + &.failed-card { + border-color: #c84040; + } + } + + .notification-id, + .notification-queued-time, + .notification-target, + .notification-endpoints, + .failure-time { + color: #aaa; + } + + .column-description { + color: #999; + } + + .empty-state { + background: #2d2d2d; + color: #999; + + &.failed-empty { + border-color: #66bb6a; + color: #66bb6a; + } + } + + .log-info-box { + background: #252525; + border-color: #444; + + .log-header { + border-bottom-color: #444; + + h5 { + color: #e0e0e0; + } + + .close-btn { + color: #aaa; + + &:hover { + color: #fff; + } + } + } + + .log-content { + .log-meta strong { + color: #bbb; + } + + .log-meta .notification-id, + .log-meta .endpoint-item { + color: #aaa; + } + + .log-body h6 { + color: #bbb; + } + + .log-body pre { + background: #1e1e1e; + border-color: #444; + color: #e0e0e0; + } + + .log-error { + border-top-color: #444; + + h6 { + color: #ef5350; + } + + pre { + background: #2a1f1f; + border-color: #c84040; + color: #ef9a9a; + } + } + } + } + + .retry-schedule { + background: #252525; + border-color: #444; + color: #e0e0e0; + + .note { + color: #aaa; + } + } + } +} diff --git a/changedetectionio/static/styles/scss/styles.scss b/changedetectionio/static/styles/scss/styles.scss index f5002b32a..0869cbea7 100644 --- a/changedetectionio/static/styles/scss/styles.scss +++ b/changedetectionio/static/styles/scss/styles.scss @@ -21,6 +21,7 @@ @use "parts/socket"; @use "parts/visualselector"; @use "parts/widgets"; +@use "parts/notificationsdashboard"; body { color: var(--color-text); @@ -1134,6 +1135,7 @@ ul { body.failed-notifications { #failed-notifications-icon { - display: block; + display: inline-block; + vertical-align: middle; } } diff --git a/changedetectionio/static/styles/styles.css b/changedetectionio/static/styles/styles.css index cf789167a..c3cee9b58 100644 --- a/changedetectionio/static/styles/styles.css +++ b/changedetectionio/static/styles/styles.css @@ -1 +1 @@ -:root{--color-white: #fff;--color-grey-50: #111;--color-grey-100: #262626;--color-grey-200: #333;--color-grey-300: #444;--color-grey-325: #555;--color-grey-350: #565d64;--color-grey-400: #666;--color-grey-500: #777;--color-grey-600: #999;--color-grey-700: #cbcbcb;--color-grey-750: #ddd;--color-grey-800: #e0e0e0;--color-grey-850: #eee;--color-grey-900: #f2f2f2;--color-black: #000;--color-dark-red: #a00;--color-light-red: #dd0000;--color-background-page: var(--color-grey-100);--color-background-gradient-first: #5ad8f7;--color-background-gradient-second: #2f50af;--color-background-gradient-third: #9150bf;--color-background: var(--color-white);--color-text: var(--color-grey-200);--color-link: #1b98f8;--color-menu-accent: #ed5900;--color-background-code: var(--color-grey-850);--color-error: var(--color-dark-red);--color-error-input: #ffebeb;--color-error-list: var(--color-light-red);--color-table-background: var(--color-background);--color-table-stripe: var(--color-grey-900);--color-text-tab: var(--color-white);--color-background-tab: rgba(255, 255, 255, 0.2);--color-background-tab-hover: rgba(255, 255, 255, 0.5);--color-text-tab-active: #222;--color-api-key: #0078e7;--color-background-button-primary: #0078e7;--color-background-button-green: #42dd53;--color-background-button-red: #dd4242;--color-background-button-success: rgb(28, 184, 65);--color-background-button-error: rgb(202, 60, 60);--color-text-button-error: var(--color-white);--color-background-button-warning: rgb(202, 60, 60);--color-text-button-warning: var(--color-white);--color-background-button-secondary: rgb(66, 184, 221);--color-background-button-cancel: rgb(200, 200, 200);--color-text-button: var(--color-white);--color-background-button-tag: rgb(99, 99, 99);--color-background-snapshot-age: #dfdfdf;--color-error-text-snapshot-age: var(--color-white);--color-error-background-snapshot-age: #ff0000;--color-background-button-tag-active: #9c9c9c;--color-text-messages: var(--color-white);--color-background-messages-message: rgba(255, 255, 255, .2);--color-background-messages-error: rgba(255, 1, 1, .5);--color-background-messages-notice: rgba(255, 255, 255, .5);--color-border-notification: #ccc;--color-background-checkbox-operations: rgba(0, 0, 0, 0.05);--color-warning: #ff3300;--color-border-warning: var(--color-warning);--color-text-legend: var(--color-white);--color-link-new-version: #e07171;--color-last-checked: #bbb;--color-text-footer: #444;--color-border-watch-table-cell: #eee;--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);--color-background-new-watch-form: rgba(0, 0, 0, 0.05);--color-background-new-watch-input: var(--color-white);--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);--color-text-new-watch-input: var(--color-text);--color-border-input: var(--color-grey-500);--color-shadow-input: var(--color-grey-400);--color-background-input: var(--color-white);--color-text-input: var(--color-text);--color-text-input-description: var(--color-grey-500);--color-text-input-placeholder: var(--color-grey-600);--color-background-table-thead: var(--color-grey-800);--color-border-table-cell: var(--color-grey-700);--color-text-menu-heading: var(--color-grey-350);--color-text-menu-link: var(--color-grey-500);--color-background-menu-link-hover: var(--color-grey-850);--color-text-menu-link-hover: var(--color-grey-300);--color-shadow-jump: var(--color-grey-500);--color-icon-github: var(--color-black);--color-icon-github-hover: var(--color-grey-300);--color-watch-table-error: var(--color-dark-red);--color-watch-table-row-text: var(--color-grey-100)}html[data-darkmode=true]{--color-link: #59bdfb;--color-text: var(--color-white);--color-background-gradient-first: #3f90a5;--color-background-gradient-second: #1e316c;--color-background-gradient-third: #4d2c64;--color-background-new-watch-input: var(--color-grey-100);--color-background-new-watch-input-transparent: var(--color-grey-100);--color-text-new-watch-input: var(--color-text);--color-background-table-thead: var(--color-grey-200);--color-table-background: var(--color-grey-300);--color-table-stripe: var(--color-grey-325);--color-background: var(--color-grey-300);--color-text-menu-heading: var(--color-grey-850);--color-text-menu-link: var(--color-grey-800);--color-border-table-cell: var(--color-grey-400);--color-text-tab-active: var(--color-text);--color-border-input: var(--color-grey-400);--color-shadow-input: var(--color-grey-50);--color-background-input: var(--color-grey-350);--color-text-input-description: var(--color-grey-600);--color-text-input-placeholder: var(--color-grey-600);--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);--color-background-code: var(--color-grey-200);--color-background-tab: rgba(0, 0, 0, 0.2);--color-background-tab-hover: rgba(0, 0, 0, 0.5);--color-background-snapshot-age: var(--color-grey-200);--color-shadow-jump: var(--color-grey-200);--color-icon-github: var(--color-white);--color-icon-github-hover: var(--color-grey-700);--color-watch-table-error: var(--color-light-red);--color-watch-table-row-text: var(--color-grey-800)}html[data-darkmode=true] .icon-spread{filter:hue-rotate(-10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .title-col a[target=_blank]::after,html[data-darkmode=true] .watch-table .current-diff-url::after{filter:invert(0.5) hue-rotate(10deg) brightness(2)}html[data-darkmode=true] .watch-table .status-browsersteps{filter:invert(0.5) hue-rotate(10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .watch-controls .state-off img{opacity:.3}html[data-darkmode=true] .watch-table .watch-controls .state-on img{opacity:1}html[data-darkmode=true] .watch-table .unviewed{color:#fff}html[data-darkmode=true] .watch-table .unviewed.error{color:var(--color-watch-table-error)}.arrow{border:solid #1b98f8;border-width:0 2px 2px 0;display:inline-block;padding:3px}.arrow.right{transform:rotate(-45deg);-webkit-transform:rotate(-45deg)}.arrow.left{transform:rotate(135deg);-webkit-transform:rotate(135deg)}.arrow.up,.arrow.asc{transform:rotate(-135deg);-webkit-transform:rotate(-135deg)}.arrow.down,.arrow.desc{transform:rotate(45deg);-webkit-transform:rotate(45deg)}#browser_steps th{display:none}#browser_steps li{list-style:decimal;padding:5px}#browser_steps li.browser-step-with-error{background-color:#ffd6d6;border-radius:4px}#browser_steps li:not(:first-child):hover{opacity:1}#browser_steps li .control{padding-left:5px;padding-right:5px}#browser_steps li .control a{font-size:70%}#browser_steps li.empty{padding:0px;opacity:.35}#browser_steps li.empty .control{display:none}#browser_steps li:hover{background:#eee}#browser_steps li>label{display:none}@media only screen and (min-width: 760px){#browser-steps .flex-wrapper{display:flex;flex-flow:row;height:70vh;font-size:80%}#browser-steps .flex-wrapper #browser-steps-ui{flex-grow:1;flex-shrink:1;flex-basis:0;background-color:#eee;border-radius:5px}#browser-steps-fieldlist{flex-grow:0;flex-shrink:0;flex-basis:auto;max-width:400px;padding-left:1rem;overflow-y:scroll}#browsersteps-selector-wrapper{height:100% !important}}#browsersteps-selector-wrapper{width:100%;overflow-y:scroll;position:relative;height:80vh}#browsersteps-selector-wrapper>img{position:absolute;max-width:100%}#browsersteps-selector-wrapper>canvas{position:relative;max-width:100%}#browsersteps-selector-wrapper>canvas:hover{cursor:pointer}#browsersteps-selector-wrapper .loader{position:absolute;left:50%;top:50%;transform:translate(-50%, -50%);z-index:100;max-width:350px;text-align:center}#browsersteps-selector-wrapper .spinner,#browsersteps-selector-wrapper .spinner:after{width:80px;height:80px;font-size:3px}#browsersteps-selector-wrapper #browsersteps-click-start{color:var(--color-grey-400)}#browsersteps-selector-wrapper #browsersteps-click-start:hover{cursor:pointer}ul#requests-extra_proxies{list-style:none}ul#requests-extra_proxies li>label{display:none}ul#requests-extra_proxies table tr{display:table-row}ul#requests-extra_proxies table tr input[type=text]{width:100%}@media only screen and (min-width: 1024px){ul#requests-extra_proxies table tr{display:inline}}#request label[for=proxy]{display:inline-block}body.proxy-check-active #request .proxy-check-details{font-size:80%;color:#555;display:block;padding-left:2em;max-width:500px}body.proxy-check-active #request .proxy-timing{font-size:80%;padding-left:1rem;color:var(--color-link)}#recommended-proxy{display:grid;gap:2rem;padding-bottom:1em}@media(min-width: 991px){#recommended-proxy{grid-template-columns:repeat(2, 1fr)}}#recommended-proxy>div{border:1px #aaa solid;border-radius:4px;padding:1em}#extra-proxies-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}ul#requests-extra_browsers{list-style:none}ul#requests-extra_browsers li>label{display:none}ul#requests-extra_browsers table tr{display:table-row}ul#requests-extra_browsers table tr input[type=text]{width:100%}@media only screen and (min-width: 1280px){ul#requests-extra_browsers table tr{display:inline}ul#requests-extra_browsers table tr input[type=text]{width:100%}}#extra-browsers-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}.pagination-page-info{color:#fff;font-size:.85rem;text-transform:capitalize}.pagination.menu>*{display:inline-block}.pagination.menu li{display:inline-block}.pagination.menu a{padding:.65rem;margin:3px;border:none;background:#444;border-radius:2px;color:var(--color-text-button)}.pagination.menu a.disabled{display:none}.pagination.menu a.active{font-weight:bold;background:#888}.pagination.menu a:hover{background:#999}.spinner,.spinner:after{border-radius:50%;width:10px;height:10px}.spinner{margin:0px auto;font-size:3px;vertical-align:middle;display:inline-block;text-indent:-9999em;border-top:1.1em solid rgba(38,104,237,.2);border-right:1.1em solid rgba(38,104,237,.2);border-bottom:1.1em solid rgba(38,104,237,.2);border-left:1.1em solid #2668ed;-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0);-webkit-animation:load8 1.1s infinite linear;animation:load8 1.1s infinite linear}@-webkit-keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}#toggle-light-mode .icon-dark{display:none}html[data-darkmode=true] #toggle-light-mode .icon-light{display:none}html[data-darkmode=true] #toggle-light-mode .icon-dark{display:block}.pure-menu-link{padding:.5rem 1em;line-height:1.2rem}.pure-menu-item svg{height:1.2rem}.pure-menu-item *{vertical-align:middle}.pure-menu-item .github-link{height:1.8rem;display:block}.pure-menu-item .github-link svg{height:100%}.pure-menu-item .bi-heart:hover{cursor:pointer}#overlay{opacity:.95;position:fixed;width:350px;max-width:100%;height:100%;top:0;right:-350px;background-color:var(--color-table-stripe);z-index:2;transform:translateX(0);transition:transform .5s ease}#overlay.visible{transform:translateX(-100%)}#overlay .content{font-size:.875rem;padding:1rem;margin-top:5rem;max-width:400px;color:var(--color-watch-table-row-text)}#heartpath{transition:all ease .3s !important}#heartpath:hover{fill:red !important;transition:all ease .3s !important}.minitabs-wrapper{width:100%}.minitabs-wrapper>div[id]{padding:20px;border:1px solid #ccc;border-top:none}.minitabs-wrapper .minitabs-content{width:100%;display:flex}.minitabs-wrapper .minitabs-content>div{flex:1 1 auto;min-width:0;overflow:scroll}.minitabs-wrapper .minitabs{display:flex;border-bottom:1px solid #ccc}.minitabs-wrapper .minitab{flex:1;text-align:center;padding:12px 0;text-decoration:none;color:#333;background-color:#f1f1f1;border:1px solid #ccc;border-bottom:none;cursor:pointer;transition:background-color .3s}.minitabs-wrapper .minitab:hover{background-color:#ddd}.minitabs-wrapper .minitab.active{background-color:#fff;font-weight:bold}@media(min-width: 800px){body.preview-text-enabled #filters-and-triggers>div{display:flex;gap:20px;position:relative}}body.preview-text-enabled #edit-text-filter,body.preview-text-enabled #text-preview{flex:1;align-self:flex-start}body.preview-text-enabled #edit-text-filter #pro-tips{display:none}body.preview-text-enabled #text-preview{position:sticky;top:20px;padding-top:1rem;padding-bottom:1rem;display:block !important}body.preview-text-enabled #activate-text-preview{background-color:var(--color-grey-500)}body.preview-text-enabled .monospace-preview{background:var(--color-background-input);border:1px solid var(--color-grey-600);padding:1rem;color:var(--color-text-input);font-family:"Courier New",Courier,monospace;font-size:70%;word-break:break-word;white-space:pre-wrap}#activate-text-preview{right:0;position:absolute;z-index:3;box-shadow:1px 1px 4px var(--color-shadow-jump)}.watch-table{width:100%;font-size:80%}.watch-table tr{color:var(--color-watch-table-row-text)}.watch-table tr.unviewed{font-weight:bold}.watch-table td{white-space:nowrap}.watch-table td.title-col{word-break:break-all;white-space:normal}.watch-table td a.external::after{content:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);margin:0 3px 0 5px}.watch-table th{white-space:nowrap}.watch-table th a{font-weight:normal}.watch-table th a.active{font-weight:bolder}.watch-table th a.inactive .arrow{display:none}.watch-table tr.checking-now td:first-child{position:relative}.watch-table tr.checking-now td:first-child::before{content:"";position:absolute;top:0;bottom:0;left:0;width:3px;background-color:#293eff}.watch-table tr.checking-now td.last-checked .spinner-wrapper{display:inline-block !important}.watch-table tr.checking-now td.last-checked .innertext{display:none !important}.watch-table tr.queued a.recheck{display:none !important}.watch-table tr.queued a.already-in-queue-button{display:inline-block !important}.watch-table tr.paused a.pause-toggle.state-on{display:inline !important}.watch-table tr.paused a.pause-toggle.state-off{display:none !important}.watch-table tr.notification_muted a.mute-toggle.state-on{display:inline !important}.watch-table tr.notification_muted a.mute-toggle.state-off{display:none !important}.watch-table tr.has-error{color:var(--color-watch-table-error)}.watch-table tr.has-error .error-text{display:block !important}.watch-table tr.single-history a.preview-link{display:inline-block !important}.watch-table tr.multiple-history a.history-link{display:inline-block !important}#watch-table-wrapper #post-list-buttons{text-align:right;padding:0px;margin:0px}#watch-table-wrapper #post-list-buttons li{display:inline-block}#watch-table-wrapper #post-list-buttons a{border-top-left-radius:initial;border-top-right-radius:initial;border-bottom-left-radius:5px;border-bottom-right-radius:5px}#watch-table-wrapper.has-error #post-list-buttons #post-list-with-errors{display:inline-block !important}#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-mark-views,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread{display:inline-block !important}@media(max-width: 767px){.watch-table thead{display:block}.watch-table thead tr th{display:inline-block}}@media(max-width: 767px)and (max-width: 768px){.watch-table thead tr th .hide-on-mobile{display:none}}@media(max-width: 767px){.watch-table thead .empty-cell{display:none}.watch-table .last-checked{margin-left:calc(20px + .5rem)}.watch-table .last-checked>span{vertical-align:middle}.watch-table .last-changed{margin-left:calc(20px + .5rem)}.watch-table .last-checked::before{color:var(--color-text);content:"Last Checked "}.watch-table .last-changed::before{color:var(--color-text);content:"Last Changed "}.watch-table td.inline{display:inline-block}.watch-table .pure-table td,.watch-table .pure-table th{border:none}.watch-table td{border:none;border-bottom:1px solid var(--color-border-watch-table-cell);vertical-align:middle}.watch-table td:before{top:6px;left:6px;width:45%;padding-right:10px;white-space:nowrap}.watch-table.pure-table-striped tr{background-color:var(--color-table-background)}.watch-table.pure-table-striped tr:nth-child(2n-1){background-color:var(--color-table-stripe)}.watch-table.pure-table-striped tr:nth-child(2n-1) td{background-color:inherit}}@media(max-width: 767px){.watch-table tbody tr{padding-bottom:10px;padding-top:10px;display:grid;grid-template-columns:20px 1fr 100px;grid-template-rows:auto auto auto auto;gap:.5rem}.watch-table tbody tr .counter-i{display:none}.watch-table tbody tr td.checkbox-uuid{display:grid;place-items:center}.watch-table tbody tr>td{border-bottom:none}.watch-table tbody tr>td.title-col{grid-column:1/-1;grid-row:1}.watch-table tbody tr>td.title-col .watch-title{font-size:.92rem}.watch-table tbody tr>td.title-col .link-spread{display:none}.watch-table tbody tr>td.last-checked{grid-column:1/-1;grid-row:2}.watch-table tbody tr>td.last-changed{grid-column:1/-1;grid-row:3}.watch-table tbody tr>td.checkbox-uuid{grid-column:1;grid-row:4}.watch-table tbody tr>td.buttons{grid-column:2;grid-row:4;display:flex;align-items:center;justify-content:flex-start}.watch-table tbody tr>td.watch-controls{grid-column:3;grid-row:4;display:grid;place-items:center}.watch-table tbody tr>td.watch-controls a img{padding:10px}.pure-table td{padding:3px !important}}ul#conditions_match_logic{list-style:none}ul#conditions_match_logic input,ul#conditions_match_logic label,ul#conditions_match_logic li{display:inline-block}ul#conditions_match_logic li{padding-right:1em}.fieldlist_formfields{width:100%;background-color:var(--color-background, #fff);border-radius:4px;border:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header{display:flex;background-color:var(--color-background-table-thead, #e0e0e0);font-weight:bold;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header-cell{flex:1;padding:.5em 1em;text-align:left}.fieldlist_formfields .fieldlist-header-cell:last-child{flex:0 0 120px}.fieldlist_formfields .fieldlist-body{display:flex;flex-direction:column}.fieldlist_formfields .fieldlist-row{display:flex;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-row:last-child{border-bottom:none}.fieldlist_formfields .fieldlist-row:nth-child(2n-1){background-color:var(--color-table-stripe, #f2f2f2)}.fieldlist_formfields .fieldlist-row.error-row{background-color:var(--color-error-input, #ffdddd)}.fieldlist_formfields .fieldlist-cell{flex:1;padding:.5em 1em;display:flex;flex-direction:column;justify-content:center}.fieldlist_formfields .fieldlist-cell input,.fieldlist_formfields .fieldlist-cell select{width:100%}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:0 0 120px;display:flex;flex-direction:row;align-items:center;gap:4px}.fieldlist_formfields ul.errors{margin-top:.5em;margin-bottom:0;padding:.5em;background-color:var(--color-error-background-snapshot-age, #ffdddd);border-radius:4px;list-style-position:inside}@media only screen and (max-width: 760px){.fieldlist_formfields .fieldlist-header,.fieldlist_formfields .fieldlist-row{flex-direction:column}.fieldlist_formfields .fieldlist-header-cell{display:none}.fieldlist_formfields .fieldlist-row{padding:.5em 0;border-bottom:2px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-cell{padding:.25em .5em}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:1;justify-content:flex-start;padding-top:.5em}.fieldlist_formfields .fieldlist-cell:not(:last-child){margin-bottom:.5em}.fieldlist_formfields .fieldlist-cell::before{content:attr(data-label);font-weight:bold;margin-bottom:.25em}}.fieldlist_formfields .addRuleRow,.fieldlist_formfields .removeRuleRow,.fieldlist_formfields .verifyRuleRow{cursor:pointer;border:none;padding:4px 8px;border-radius:3px;font-weight:bold;background-color:#aaa;color:var(--color-foreground-text, #fff)}.fieldlist_formfields .addRuleRow:hover,.fieldlist_formfields .removeRuleRow:hover,.fieldlist_formfields .verifyRuleRow:hover{background-color:#999}.watch-table.favicon-not-enabled tr .favicon{display:none}.watch-table tr td.inline.title-col .flex-wrapper{display:flex;align-items:center;gap:4px}.watch-table td,.watch-table th{vertical-align:middle}.watch-table tr.has-favicon.unviewed img.favicon{opacity:1 !important}.watch-table .status-icons{white-space:nowrap;display:flex;align-items:center;gap:4px}.watch-table .status-icons>*{vertical-align:middle}.title-col{padding:10px}.title-wrapper{display:flex;align-items:center;gap:10px}.title-col-inner{display:inline-block;vertical-align:middle}.watch-table img.favicon{vertical-align:middle;max-width:25px;max-height:25px;height:25px;padding-right:4px}body.checking-now #checking-now-fixed-tab{display:block !important}#checking-now-fixed-tab{background:#ccc;border-radius:5px;bottom:0;color:var(--color-text);display:none;font-size:.8rem;left:0;padding:5px;position:fixed}#selector-wrapper{height:100%;text-align:center;max-height:70vh;overflow-y:scroll;position:relative}#selector-wrapper>img{position:absolute;z-index:4;max-width:100%}#selector-wrapper>canvas{position:relative;z-index:5;max-width:100%}#selector-wrapper>canvas:hover{cursor:pointer}#selector-current-xpath{font-size:80%}.ternary-radio-group{display:flex;gap:0;border:1px solid var(--color-grey-750);border-radius:4px;overflow:hidden;width:fit-content;background:var(--color-background)}.ternary-radio-group .ternary-radio-option{position:relative;cursor:pointer;margin:0;display:flex;align-items:center}.ternary-radio-group .ternary-radio-option input[type=radio]{position:absolute;opacity:0;width:0;height:0}.ternary-radio-group .ternary-radio-option .ternary-radio-label{padding:8px 16px;background:var(--color-grey-900);border:none;border-right:1px solid var(--color-grey-750);font-size:13px;font-weight:500;color:var(--color-text);transition:all .2s ease;cursor:pointer;display:block;text-align:center}.ternary-radio-group .ternary-radio-option:last-child .ternary-radio-label{border-right:none}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button);font-weight:600}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600);color:var(--color-text-button)}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}.ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-800)}@media(max-width: 480px){.ternary-radio-group{width:100%}.ternary-radio-group .ternary-radio-label{flex:1;min-width:auto}}input[type=radio].pure-radio:checked+label,input[type=radio].pure-radio:checked{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option .ternary-radio-label{background:var(--color-grey-350)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-400)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}body{color:var(--color-text);background:var(--color-background-page);font-family:Helvetica Neue,Helvetica,Lucida Grande,Arial,Ubuntu,Cantarell,Fira Sans,sans-serif}.visually-hidden{clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}.status-icon{display:inline-block;height:1rem;vertical-align:middle}.pure-table-even{background:var(--color-background)}a{text-decoration:none;color:var(--color-link)}a.github-link{color:var(--color-icon-github);margin:0 1rem 0 .5rem}a.github-link svg{fill:currentColor}a.github-link:hover{color:var(--color-icon-github-hover)}#search-q{opacity:0;-webkit-transition:all .9s ease;-moz-transition:all .9s ease;transition:all .9s ease;width:0;display:none}#search-q.expanded{width:auto;display:inline-block;opacity:1}#search-result-info{color:#fff}button.toggle-button{vertical-align:middle;background:rgba(0,0,0,0);border:none;cursor:pointer;color:var(--color-icon-github)}button.toggle-button:hover{color:var(--color-icon-github-hover)}button.toggle-button svg{fill:currentColor}button.toggle-button .icon-light{display:block}.pure-menu-horizontal{background:var(--color-background);padding:5px;display:flex;justify-content:space-between;align-items:center}#pure-menu-horizontal-spinner{height:3px;background:linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);background-size:400% 400%;width:100%;animation:gradient 200s ease infinite}body.spinner-active #pure-menu-horizontal-spinner{animation:gradient 1s ease infinite}@keyframes gradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}.pure-menu-heading{color:var(--color-text-menu-heading)}.pure-menu-link{color:var(--color-text-menu-link)}.pure-menu-link:hover{background-color:var(--color-background-menu-link-hover);color:var(--color-text-menu-link-hover)}.tab-pane-inner{scroll-margin-top:200px}section.content{padding-top:100px;padding-bottom:1em;flex-direction:column;display:flex;align-items:center;justify-content:center}code{background:var(--color-background-code);color:var(--color-text)}.inline-tag,.restock-label,.tracking-ldjson-price-data,.watch-tag-list{white-space:nowrap;border-radius:5px;padding:2px 5px;margin-right:4px}.watch-tag-list{color:var(--color-white);background:var(--color-text-watch-tag-list)}@media(min-width: 768px){.box{margin:0 1em !important}}.box{max-width:100%;margin:0 .3em;flex-direction:column;display:flex;justify-content:center}body:after{content:"";background:linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%)}body:after,body:before{display:block;height:650px;position:absolute;top:0;left:0;width:100%;z-index:-1}body::after{opacity:.91}body::before{content:""}body:after,body:before{-webkit-clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%);clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%)}.button-small{font-size:85%}.button-xsmall{font-size:70%}.fetch-error{padding-top:1em;font-size:80%;max-width:400px;display:block}.pure-button-primary,a.pure-button-primary,.pure-button-selected,a.pure-button-selected{background-color:var(--color-background-button-primary)}.button-secondary{color:var(--color-text-button);border-radius:4px;text-shadow:0 1px 1px rgba(0,0,0,.2)}.button-success{background:var(--color-background-button-success)}.button-tag{background:var(--color-background-button-tag);color:var(--color-text-button);font-size:65%;border-bottom-left-radius:initial;border-bottom-right-radius:initial;margin-right:4px}.button-tag.active{background:var(--color-background-button-tag-active);font-weight:bold}.button-error{background:var(--color-background-button-error);color:var(--color-text-button-error)}.button-warning{background:var(--color-background-button-warning);color:var(--color-text-button-warning)}.button-secondary{background:var(--color-background-button-secondary)}.button-cancel{background:var(--color-background-button-cancel)}.messages li{list-style:none;padding:1em;border-radius:10px;color:var(--color-text-messages);font-weight:bold}.messages li.message{background:var(--color-background-messages-message)}.messages li.error{background:var(--color-background-messages-error)}.messages li.notice{background:var(--color-background-messages-notice)}.messages.with-share-link>*:hover{cursor:pointer}.notifications-wrapper{padding-top:.5rem}.notifications-wrapper #notification-test-log{margin-top:1rem;padding:1rem;white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word;max-width:100%;box-sizing:border-box;max-height:12rem;overflow-y:scroll;border:1px solid var(--color-border-notification);border-radius:5px}label:hover{cursor:pointer}.grey-form-border{border:1px solid var(--color-border-notification);padding:.5rem;border-radius:5px}#notification-error-log{border:1px solid var(--color-border-notification);padding:1rem;border-radius:5px;overflow-wrap:break-word}#token-table.pure-table td,#token-table.pure-table th{font-size:80%}.pure-form input[type=text].transparent-field{background-color:var(--color-background-new-watch-input-transparent) !important;color:var(--color-white) !important;border:1px solid hsla(0,0%,100%,.2) !important;box-shadow:none !important;-webkit-box-shadow:none !important}.pure-form input[type=text].transparent-field::placeholder{opacity:.5;color:hsla(0,0%,100%,.7);font-weight:lighter}#new-watch-form{background:var(--color-background-new-watch-form);padding:1em;border-radius:10px;margin-bottom:1em;max-width:100%}#new-watch-form #url::placeholder{font-weight:bold}#new-watch-form input{display:inline-block;margin-bottom:5px}#new-watch-form input:not(.pure-button){background-color:var(--color-background-new-watch-input);color:var(--color-text-new-watch-input)}#new-watch-form .label{display:none}#new-watch-form legend{color:var(--color-text-legend);font-weight:bold}@media only screen and (min-width: 760px){#new-watch-form #watch-add-wrapper-zone{display:flex;gap:.3rem;flex-direction:row;min-width:70vw}}#new-watch-form #watch-add-wrapper-zone>span{flex-grow:0}#new-watch-form #watch-add-wrapper-zone>span input{width:100%;padding-right:1em}#new-watch-form #watch-add-wrapper-zone>span:first-child{flex-grow:1}@media only screen and (max-width: 760px){#new-watch-form #watch-add-wrapper-zone #url{width:100%}}#new-watch-form #watch-group-tag{font-size:.9rem;padding:.3rem;display:flex;align-items:center;gap:.5rem;color:var(--color-white)}#new-watch-form #watch-group-tag label,#new-watch-form #watch-group-tag input{margin:0}#new-watch-form #watch-group-tag input{flex:1}#diff-col{padding-left:40px}#diff-jump{position:fixed;left:0px;top:120px;background:var(--color-background);padding:10px;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}#diff-jump a{color:var(--color-link);cursor:pointer;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;-o-user-select:none}footer{padding:10px;background:var(--color-background);color:var(--color-text-footer);text-align:center}#feed-icon{vertical-align:middle}.sticky-tab{position:absolute;top:60px;font-size:65%;background:var(--color-background);padding:10px}.sticky-tab#left-sticky{left:0;position:fixed;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}.sticky-tab#right-sticky{right:0px}.sticky-tab#hosted-sticky{right:0px;top:100px;font-weight:bold}#new-version-text a{color:var(--color-link-new-version)}.watch-controls{color:#f8321b}.watch-controls .state-on img{opacity:.8}.watch-controls img{opacity:.2}.watch-controls img:hover{transition:opacity .3s;opacity:.8}.monospaced-textarea textarea{width:100%;font-family:monospace;white-space:pre;overflow-wrap:normal;overflow-x:auto}.pure-form fieldset{padding-top:0px}.pure-form fieldset ul{padding-bottom:0px;margin-bottom:0px}.pure-form .pure-control-group,.pure-form .pure-group,.pure-form .pure-controls{padding-bottom:1em}.pure-form .pure-control-group div,.pure-form .pure-group div,.pure-form .pure-controls div{margin:0px}.pure-form .pure-control-group .checkbox>*,.pure-form .pure-group .checkbox>*,.pure-form .pure-controls .checkbox>*{display:inline;vertical-align:middle}.pure-form .pure-control-group .checkbox>label,.pure-form .pure-group .checkbox>label,.pure-form .pure-controls .checkbox>label{padding-left:5px}.pure-form .pure-control-group legend,.pure-form .pure-group legend,.pure-form .pure-controls legend{color:var(--color-text-legend)}.pure-form .error input{background-color:var(--color-error-input)}.pure-form ul.errors{padding:.5em .6em;border:1px solid var(--color-error-list);border-radius:4px;vertical-align:middle;-webkit-box-sizing:border-box;box-sizing:border-box}.pure-form ul.errors li{margin-left:1em;color:var(--color-error-list)}.pure-form label{font-weight:bold}.pure-form textarea{width:100%}.pure-form .inline-radio ul{margin:0px;list-style:none}.pure-form .inline-radio ul li{display:flex;align-items:center;gap:1em}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 1024px){.edit-form{padding:.5em;margin:0}#nav-menu{overflow-x:scroll}}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 800px){div.sticky-tab#hosted-sticky{top:60px;left:0px;right:auto}section.content{padding-top:110px}div.tabs.collapsable ul li{display:block;border-radius:0px;margin-right:0px}input[type=text]{width:100%}}.pure-table{border-color:var(--color-border-table-cell)}.pure-table thead{background-color:var(--color-background-table-thead);color:var(--color-text);border-bottom:1px solid var(--color-background-table-thead)}.pure-table td,.pure-table th{border-left-color:var(--color-border-table-cell)}.pure-table-striped tr:nth-child(2n-1) td{background-color:var(--color-table-stripe)}.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form select,.pure-form textarea{border:var(--color-border-input);box-shadow:inset 0 1px 3px var(--color-shadow-input);background-color:var(--color-background-input);color:var(--color-text-input)}.pure-form input[type=color]:active,.pure-form input[type=date]:active,.pure-form input[type=datetime-local]:active,.pure-form input[type=datetime]:active,.pure-form input[type=email]:active,.pure-form input[type=month]:active,.pure-form input[type=number]:active,.pure-form input[type=password]:active,.pure-form input[type=search]:active,.pure-form input[type=tel]:active,.pure-form input[type=text]:active,.pure-form input[type=time]:active,.pure-form input[type=url]:active,.pure-form input[type=week]:active,.pure-form select:active,.pure-form textarea:active{background-color:var(--color-background-input)}input::placeholder,textarea::placeholder{color:var(--color-text-input-placeholder)}.m-d{min-width:100%}@media only screen and (min-width: 761px){.m-d{min-width:80%}}.tabs ul{margin:0px;padding:0px;display:block}.tabs ul li{margin-right:3px;display:inline-block;color:var(--color-text-tab);border-top-left-radius:5px;border-top-right-radius:5px;background-color:var(--color-background-tab)}.tabs ul li:not(.active):hover{background-color:var(--color-background-tab-hover)}.tabs ul li.active,.tabs ul li :target{background-color:var(--color-background)}.tabs ul li.active a,.tabs ul li :target a{color:var(--color-text-tab-active);font-weight:bold}.tabs ul li a{display:block;padding:.8em;color:var(--color-text-tab)}.pure-form-stacked>div:first-child{display:block}.login-form .inner{background:var(--color-background);padding:20px;border-radius:5px}.tab-pane-inner{padding:0px}.tab-pane-inner:not(:target){display:none}.tab-pane-inner:target{display:block}.beta-logo{height:50px;right:-3px;top:-3px;position:absolute}#selector-header{padding-bottom:1em}body.full-width .edit-form{width:95%}.edit-form{min-width:70%;max-width:95%}.edit-form .box-wrap{position:relative}.edit-form .inner{background:var(--color-background);padding:20px}.edit-form #actions{display:block;background:var(--color-background)}.edit-form #actions .pure-control-group{display:flex;gap:.625em;flex-wrap:wrap}.edit-form .pure-form-message-inline{padding-left:0;color:var(--color-text-input-description)}.edit-form .pure-form-message-inline code{font-size:.875em}.border-fieldset{border:1px solid #ccc;padding:1rem;border-radius:5px;margin-bottom:1rem}.border-fieldset h3{margin-top:0}.border-fieldset fieldset:last-of-type{padding-bottom:0}.border-fieldset fieldset:last-of-type .pure-control-group{padding-bottom:0}ul{padding-left:1em;padding-top:0px;margin-top:4px}.time-check-widget tr{display:inline}.time-check-widget tr input[type=number]{width:5em}@media only screen and (max-width: 760px){.time-check-widget tbody{display:grid;grid-template-columns:auto 1fr auto 1fr;gap:.625em .3125em;align-items:center}.time-check-widget tr{display:contents}.time-check-widget tr th{text-align:right;padding-right:5px}.time-check-widget tr input[type=number]{width:100%;max-width:5em}}#webdriver_delay{width:5em}#api-key:hover{cursor:pointer}#api-key-copy{color:var(--color-api-key)}.button-green{background-color:var(--color-background-button-green)}.button-red{background-color:var(--color-background-button-red)}.noselect{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}.snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#checkbox-operations{background:var(--color-background-checkbox-operations);padding:1em;border-radius:10px;margin-bottom:1em;display:none}#checkbox-operations button{margin-bottom:3px;margin-top:3px;display:inline-flex;align-items:center}.checkbox-uuid>*{vertical-align:middle}.inline-warning{border:1px solid var(--color-border-warning);padding:.5rem;border-radius:5px;color:var(--color-warning)}.inline-warning>span{display:inline-block;vertical-align:middle}.inline-warning img.inline-warning-icon{display:inline;height:26px;vertical-align:middle}.tracking-ldjson-price-data{background-color:var(--color-background-button-green);color:#000;opacity:.6}.ldjson-price-track-offer{font-weight:bold;font-style:italic}.ldjson-price-track-offer a.pure-button{border-radius:3px;padding:3px;background-color:var(--color-background-button-green)}.price-follow-tag-icon{display:inline-block;height:.8rem;vertical-align:middle}#quick-watch-processor-type ul#processor{color:#fff;padding-left:0px}#quick-watch-processor-type ul#processor li{list-style:none;font-size:.9rem;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:.5rem;margin-bottom:.5rem}#quick-watch-processor-type label,#quick-watch-processor-type input{padding:0;margin:0}.restock-label.in-stock{background-color:var(--color-background-button-green);color:#fff}.restock-label.not-in-stock{background-color:var(--color-background-button-cancel);color:#777}.restock-label.error{background-color:var(--color-background-button-error);color:#fff;opacity:.7}.restock-label svg{vertical-align:middle}#chrome-extension-link{padding:9px;border:1px solid var(--color-grey-800);border-radius:10px;vertical-align:middle}#chrome-extension-link img{height:21px;padding:2px;vertical-align:middle}#realtime-conn-error{position:fixed;bottom:0;left:0;background:var(--color-warning);padding:10px;font-size:.8rem;color:#fff;opacity:.8}#failed-notifications-icon{display:none}body.failed-notifications #failed-notifications-icon{display:block} +:root{--color-white: #fff;--color-grey-50: #111;--color-grey-100: #262626;--color-grey-200: #333;--color-grey-300: #444;--color-grey-325: #555;--color-grey-350: #565d64;--color-grey-400: #666;--color-grey-500: #777;--color-grey-600: #999;--color-grey-700: #cbcbcb;--color-grey-750: #ddd;--color-grey-800: #e0e0e0;--color-grey-850: #eee;--color-grey-900: #f2f2f2;--color-black: #000;--color-dark-red: #a00;--color-light-red: #dd0000;--color-background-page: var(--color-grey-100);--color-background-gradient-first: #5ad8f7;--color-background-gradient-second: #2f50af;--color-background-gradient-third: #9150bf;--color-background: var(--color-white);--color-text: var(--color-grey-200);--color-link: #1b98f8;--color-menu-accent: #ed5900;--color-background-code: var(--color-grey-850);--color-error: var(--color-dark-red);--color-error-input: #ffebeb;--color-error-list: var(--color-light-red);--color-table-background: var(--color-background);--color-table-stripe: var(--color-grey-900);--color-text-tab: var(--color-white);--color-background-tab: rgba(255, 255, 255, 0.2);--color-background-tab-hover: rgba(255, 255, 255, 0.5);--color-text-tab-active: #222;--color-api-key: #0078e7;--color-background-button-primary: #0078e7;--color-background-button-green: #42dd53;--color-background-button-red: #dd4242;--color-background-button-success: rgb(28, 184, 65);--color-background-button-error: rgb(202, 60, 60);--color-text-button-error: var(--color-white);--color-background-button-warning: rgb(202, 60, 60);--color-text-button-warning: var(--color-white);--color-background-button-secondary: rgb(66, 184, 221);--color-background-button-cancel: rgb(200, 200, 200);--color-text-button: var(--color-white);--color-background-button-tag: rgb(99, 99, 99);--color-background-snapshot-age: #dfdfdf;--color-error-text-snapshot-age: var(--color-white);--color-error-background-snapshot-age: #ff0000;--color-background-button-tag-active: #9c9c9c;--color-text-messages: var(--color-white);--color-background-messages-message: rgba(255, 255, 255, .2);--color-background-messages-error: rgba(255, 1, 1, .5);--color-background-messages-notice: rgba(255, 255, 255, .5);--color-border-notification: #ccc;--color-background-checkbox-operations: rgba(0, 0, 0, 0.05);--color-warning: #ff3300;--color-border-warning: var(--color-warning);--color-text-legend: var(--color-white);--color-link-new-version: #e07171;--color-last-checked: #bbb;--color-text-footer: #444;--color-border-watch-table-cell: #eee;--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);--color-background-new-watch-form: rgba(0, 0, 0, 0.05);--color-background-new-watch-input: var(--color-white);--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);--color-text-new-watch-input: var(--color-text);--color-border-input: var(--color-grey-500);--color-shadow-input: var(--color-grey-400);--color-background-input: var(--color-white);--color-text-input: var(--color-text);--color-text-input-description: var(--color-grey-500);--color-text-input-placeholder: var(--color-grey-600);--color-background-table-thead: var(--color-grey-800);--color-border-table-cell: var(--color-grey-700);--color-text-menu-heading: var(--color-grey-350);--color-text-menu-link: var(--color-grey-500);--color-background-menu-link-hover: var(--color-grey-850);--color-text-menu-link-hover: var(--color-grey-300);--color-shadow-jump: var(--color-grey-500);--color-icon-github: var(--color-black);--color-icon-github-hover: var(--color-grey-300);--color-watch-table-error: var(--color-dark-red);--color-watch-table-row-text: var(--color-grey-100)}html[data-darkmode=true]{--color-link: #59bdfb;--color-text: var(--color-white);--color-background-gradient-first: #3f90a5;--color-background-gradient-second: #1e316c;--color-background-gradient-third: #4d2c64;--color-background-new-watch-input: var(--color-grey-100);--color-background-new-watch-input-transparent: var(--color-grey-100);--color-text-new-watch-input: var(--color-text);--color-background-table-thead: var(--color-grey-200);--color-table-background: var(--color-grey-300);--color-table-stripe: var(--color-grey-325);--color-background: var(--color-grey-300);--color-text-menu-heading: var(--color-grey-850);--color-text-menu-link: var(--color-grey-800);--color-border-table-cell: var(--color-grey-400);--color-text-tab-active: var(--color-text);--color-border-input: var(--color-grey-400);--color-shadow-input: var(--color-grey-50);--color-background-input: var(--color-grey-350);--color-text-input-description: var(--color-grey-600);--color-text-input-placeholder: var(--color-grey-600);--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);--color-background-code: var(--color-grey-200);--color-background-tab: rgba(0, 0, 0, 0.2);--color-background-tab-hover: rgba(0, 0, 0, 0.5);--color-background-snapshot-age: var(--color-grey-200);--color-shadow-jump: var(--color-grey-200);--color-icon-github: var(--color-white);--color-icon-github-hover: var(--color-grey-700);--color-watch-table-error: var(--color-light-red);--color-watch-table-row-text: var(--color-grey-800)}html[data-darkmode=true] .icon-spread{filter:hue-rotate(-10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .title-col a[target=_blank]::after,html[data-darkmode=true] .watch-table .current-diff-url::after{filter:invert(0.5) hue-rotate(10deg) brightness(2)}html[data-darkmode=true] .watch-table .status-browsersteps{filter:invert(0.5) hue-rotate(10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .watch-controls .state-off img{opacity:.3}html[data-darkmode=true] .watch-table .watch-controls .state-on img{opacity:1}html[data-darkmode=true] .watch-table .unviewed{color:#fff}html[data-darkmode=true] .watch-table .unviewed.error{color:var(--color-watch-table-error)}.arrow{border:solid #1b98f8;border-width:0 2px 2px 0;display:inline-block;padding:3px}.arrow.right{transform:rotate(-45deg);-webkit-transform:rotate(-45deg)}.arrow.left{transform:rotate(135deg);-webkit-transform:rotate(135deg)}.arrow.up,.arrow.asc{transform:rotate(-135deg);-webkit-transform:rotate(-135deg)}.arrow.down,.arrow.desc{transform:rotate(45deg);-webkit-transform:rotate(45deg)}#browser_steps th{display:none}#browser_steps li{list-style:decimal;padding:5px}#browser_steps li.browser-step-with-error{background-color:#ffd6d6;border-radius:4px}#browser_steps li:not(:first-child):hover{opacity:1}#browser_steps li .control{padding-left:5px;padding-right:5px}#browser_steps li .control a{font-size:70%}#browser_steps li.empty{padding:0px;opacity:.35}#browser_steps li.empty .control{display:none}#browser_steps li:hover{background:#eee}#browser_steps li>label{display:none}@media only screen and (min-width: 760px){#browser-steps .flex-wrapper{display:flex;flex-flow:row;height:70vh;font-size:80%}#browser-steps .flex-wrapper #browser-steps-ui{flex-grow:1;flex-shrink:1;flex-basis:0;background-color:#eee;border-radius:5px}#browser-steps-fieldlist{flex-grow:0;flex-shrink:0;flex-basis:auto;max-width:400px;padding-left:1rem;overflow-y:scroll}#browsersteps-selector-wrapper{height:100% !important}}#browsersteps-selector-wrapper{width:100%;overflow-y:scroll;position:relative;height:80vh}#browsersteps-selector-wrapper>img{position:absolute;max-width:100%}#browsersteps-selector-wrapper>canvas{position:relative;max-width:100%}#browsersteps-selector-wrapper>canvas:hover{cursor:pointer}#browsersteps-selector-wrapper .loader{position:absolute;left:50%;top:50%;transform:translate(-50%, -50%);z-index:100;max-width:350px;text-align:center}#browsersteps-selector-wrapper .spinner,#browsersteps-selector-wrapper .spinner:after{width:80px;height:80px;font-size:3px}#browsersteps-selector-wrapper #browsersteps-click-start{color:var(--color-grey-400)}#browsersteps-selector-wrapper #browsersteps-click-start:hover{cursor:pointer}ul#requests-extra_proxies{list-style:none}ul#requests-extra_proxies li>label{display:none}ul#requests-extra_proxies table tr{display:table-row}ul#requests-extra_proxies table tr input[type=text]{width:100%}@media only screen and (min-width: 1024px){ul#requests-extra_proxies table tr{display:inline}}#request label[for=proxy]{display:inline-block}body.proxy-check-active #request .proxy-check-details{font-size:80%;color:#555;display:block;padding-left:2em;max-width:500px}body.proxy-check-active #request .proxy-timing{font-size:80%;padding-left:1rem;color:var(--color-link)}#recommended-proxy{display:grid;gap:2rem;padding-bottom:1em}@media(min-width: 991px){#recommended-proxy{grid-template-columns:repeat(2, 1fr)}}#recommended-proxy>div{border:1px #aaa solid;border-radius:4px;padding:1em}#extra-proxies-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}ul#requests-extra_browsers{list-style:none}ul#requests-extra_browsers li>label{display:none}ul#requests-extra_browsers table tr{display:table-row}ul#requests-extra_browsers table tr input[type=text]{width:100%}@media only screen and (min-width: 1280px){ul#requests-extra_browsers table tr{display:inline}ul#requests-extra_browsers table tr input[type=text]{width:100%}}#extra-browsers-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}.pagination-page-info{color:#fff;font-size:.85rem;text-transform:capitalize}.pagination.menu>*{display:inline-block}.pagination.menu li{display:inline-block}.pagination.menu a{padding:.65rem;margin:3px;border:none;background:#444;border-radius:2px;color:var(--color-text-button)}.pagination.menu a.disabled{display:none}.pagination.menu a.active{font-weight:bold;background:#888}.pagination.menu a:hover{background:#999}.spinner,.spinner:after{border-radius:50%;width:10px;height:10px}.spinner{margin:0px auto;font-size:3px;vertical-align:middle;display:inline-block;text-indent:-9999em;border-top:1.1em solid rgba(38,104,237,.2);border-right:1.1em solid rgba(38,104,237,.2);border-bottom:1.1em solid rgba(38,104,237,.2);border-left:1.1em solid #2668ed;-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0);-webkit-animation:load8 1.1s infinite linear;animation:load8 1.1s infinite linear}@-webkit-keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}#toggle-light-mode .icon-dark{display:none}html[data-darkmode=true] #toggle-light-mode .icon-light{display:none}html[data-darkmode=true] #toggle-light-mode .icon-dark{display:block}.pure-menu-link{padding:.5rem 1em;line-height:1.2rem}.pure-menu-item svg{height:1.2rem}.pure-menu-item *{vertical-align:middle}.pure-menu-item .github-link{height:1.8rem;display:block}.pure-menu-item .github-link svg{height:100%}.pure-menu-item .bi-heart:hover{cursor:pointer}#overlay{opacity:.95;position:fixed;width:350px;max-width:100%;height:100%;top:0;right:-350px;background-color:var(--color-table-stripe);z-index:2;transform:translateX(0);transition:transform .5s ease}#overlay.visible{transform:translateX(-100%)}#overlay .content{font-size:.875rem;padding:1rem;margin-top:5rem;max-width:400px;color:var(--color-watch-table-row-text)}#heartpath{transition:all ease .3s !important}#heartpath:hover{fill:red !important;transition:all ease .3s !important}.minitabs-wrapper{width:100%}.minitabs-wrapper>div[id]{padding:20px;border:1px solid #ccc;border-top:none}.minitabs-wrapper .minitabs-content{width:100%;display:flex}.minitabs-wrapper .minitabs-content>div{flex:1 1 auto;min-width:0;overflow:scroll}.minitabs-wrapper .minitabs{display:flex;border-bottom:1px solid #ccc}.minitabs-wrapper .minitab{flex:1;text-align:center;padding:12px 0;text-decoration:none;color:#333;background-color:#f1f1f1;border:1px solid #ccc;border-bottom:none;cursor:pointer;transition:background-color .3s}.minitabs-wrapper .minitab:hover{background-color:#ddd}.minitabs-wrapper .minitab.active{background-color:#fff;font-weight:bold}@media(min-width: 800px){body.preview-text-enabled #filters-and-triggers>div{display:flex;gap:20px;position:relative}}body.preview-text-enabled #edit-text-filter,body.preview-text-enabled #text-preview{flex:1;align-self:flex-start}body.preview-text-enabled #edit-text-filter #pro-tips{display:none}body.preview-text-enabled #text-preview{position:sticky;top:20px;padding-top:1rem;padding-bottom:1rem;display:block !important}body.preview-text-enabled #activate-text-preview{background-color:var(--color-grey-500)}body.preview-text-enabled .monospace-preview{background:var(--color-background-input);border:1px solid var(--color-grey-600);padding:1rem;color:var(--color-text-input);font-family:"Courier New",Courier,monospace;font-size:70%;word-break:break-word;white-space:pre-wrap}#activate-text-preview{right:0;position:absolute;z-index:3;box-shadow:1px 1px 4px var(--color-shadow-jump)}.watch-table{width:100%;font-size:80%}.watch-table tr{color:var(--color-watch-table-row-text)}.watch-table tr.unviewed{font-weight:bold}.watch-table td{white-space:nowrap}.watch-table td.title-col{word-break:break-all;white-space:normal}.watch-table td a.external::after{content:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);margin:0 3px 0 5px}.watch-table th{white-space:nowrap}.watch-table th a{font-weight:normal}.watch-table th a.active{font-weight:bolder}.watch-table th a.inactive .arrow{display:none}.watch-table tr.checking-now td:first-child{position:relative}.watch-table tr.checking-now td:first-child::before{content:"";position:absolute;top:0;bottom:0;left:0;width:3px;background-color:#293eff}.watch-table tr.checking-now td.last-checked .spinner-wrapper{display:inline-block !important}.watch-table tr.checking-now td.last-checked .innertext{display:none !important}.watch-table tr.queued a.recheck{display:none !important}.watch-table tr.queued a.already-in-queue-button{display:inline-block !important}.watch-table tr.paused a.pause-toggle.state-on{display:inline !important}.watch-table tr.paused a.pause-toggle.state-off{display:none !important}.watch-table tr.notification_muted a.mute-toggle.state-on{display:inline !important}.watch-table tr.notification_muted a.mute-toggle.state-off{display:none !important}.watch-table tr.has-error{color:var(--color-watch-table-error)}.watch-table tr.has-error .error-text{display:block !important}.watch-table tr.single-history a.preview-link{display:inline-block !important}.watch-table tr.multiple-history a.history-link{display:inline-block !important}#watch-table-wrapper #post-list-buttons{text-align:right;padding:0px;margin:0px}#watch-table-wrapper #post-list-buttons li{display:inline-block}#watch-table-wrapper #post-list-buttons a{border-top-left-radius:initial;border-top-right-radius:initial;border-bottom-left-radius:5px;border-bottom-right-radius:5px}#watch-table-wrapper.has-error #post-list-buttons #post-list-with-errors{display:inline-block !important}#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-mark-views,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread{display:inline-block !important}@media(max-width: 767px){.watch-table thead{display:block}.watch-table thead tr th{display:inline-block}}@media(max-width: 767px)and (max-width: 768px){.watch-table thead tr th .hide-on-mobile{display:none}}@media(max-width: 767px){.watch-table thead .empty-cell{display:none}.watch-table .last-checked{margin-left:calc(20px + .5rem)}.watch-table .last-checked>span{vertical-align:middle}.watch-table .last-changed{margin-left:calc(20px + .5rem)}.watch-table .last-checked::before{color:var(--color-text);content:"Last Checked "}.watch-table .last-changed::before{color:var(--color-text);content:"Last Changed "}.watch-table td.inline{display:inline-block}.watch-table .pure-table td,.watch-table .pure-table th{border:none}.watch-table td{border:none;border-bottom:1px solid var(--color-border-watch-table-cell);vertical-align:middle}.watch-table td:before{top:6px;left:6px;width:45%;padding-right:10px;white-space:nowrap}.watch-table.pure-table-striped tr{background-color:var(--color-table-background)}.watch-table.pure-table-striped tr:nth-child(2n-1){background-color:var(--color-table-stripe)}.watch-table.pure-table-striped tr:nth-child(2n-1) td{background-color:inherit}}@media(max-width: 767px){.watch-table tbody tr{padding-bottom:10px;padding-top:10px;display:grid;grid-template-columns:20px 1fr 100px;grid-template-rows:auto auto auto auto;gap:.5rem}.watch-table tbody tr .counter-i{display:none}.watch-table tbody tr td.checkbox-uuid{display:grid;place-items:center}.watch-table tbody tr>td{border-bottom:none}.watch-table tbody tr>td.title-col{grid-column:1/-1;grid-row:1}.watch-table tbody tr>td.title-col .watch-title{font-size:.92rem}.watch-table tbody tr>td.title-col .link-spread{display:none}.watch-table tbody tr>td.last-checked{grid-column:1/-1;grid-row:2}.watch-table tbody tr>td.last-changed{grid-column:1/-1;grid-row:3}.watch-table tbody tr>td.checkbox-uuid{grid-column:1;grid-row:4}.watch-table tbody tr>td.buttons{grid-column:2;grid-row:4;display:flex;align-items:center;justify-content:flex-start}.watch-table tbody tr>td.watch-controls{grid-column:3;grid-row:4;display:grid;place-items:center}.watch-table tbody tr>td.watch-controls a img{padding:10px}.pure-table td{padding:3px !important}}ul#conditions_match_logic{list-style:none}ul#conditions_match_logic input,ul#conditions_match_logic label,ul#conditions_match_logic li{display:inline-block}ul#conditions_match_logic li{padding-right:1em}.fieldlist_formfields{width:100%;background-color:var(--color-background, #fff);border-radius:4px;border:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header{display:flex;background-color:var(--color-background-table-thead, #e0e0e0);font-weight:bold;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header-cell{flex:1;padding:.5em 1em;text-align:left}.fieldlist_formfields .fieldlist-header-cell:last-child{flex:0 0 120px}.fieldlist_formfields .fieldlist-body{display:flex;flex-direction:column}.fieldlist_formfields .fieldlist-row{display:flex;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-row:last-child{border-bottom:none}.fieldlist_formfields .fieldlist-row:nth-child(2n-1){background-color:var(--color-table-stripe, #f2f2f2)}.fieldlist_formfields .fieldlist-row.error-row{background-color:var(--color-error-input, #ffdddd)}.fieldlist_formfields .fieldlist-cell{flex:1;padding:.5em 1em;display:flex;flex-direction:column;justify-content:center}.fieldlist_formfields .fieldlist-cell input,.fieldlist_formfields .fieldlist-cell select{width:100%}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:0 0 120px;display:flex;flex-direction:row;align-items:center;gap:4px}.fieldlist_formfields ul.errors{margin-top:.5em;margin-bottom:0;padding:.5em;background-color:var(--color-error-background-snapshot-age, #ffdddd);border-radius:4px;list-style-position:inside}@media only screen and (max-width: 760px){.fieldlist_formfields .fieldlist-header,.fieldlist_formfields .fieldlist-row{flex-direction:column}.fieldlist_formfields .fieldlist-header-cell{display:none}.fieldlist_formfields .fieldlist-row{padding:.5em 0;border-bottom:2px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-cell{padding:.25em .5em}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:1;justify-content:flex-start;padding-top:.5em}.fieldlist_formfields .fieldlist-cell:not(:last-child){margin-bottom:.5em}.fieldlist_formfields .fieldlist-cell::before{content:attr(data-label);font-weight:bold;margin-bottom:.25em}}.fieldlist_formfields .addRuleRow,.fieldlist_formfields .removeRuleRow,.fieldlist_formfields .verifyRuleRow{cursor:pointer;border:none;padding:4px 8px;border-radius:3px;font-weight:bold;background-color:#aaa;color:var(--color-foreground-text, #fff)}.fieldlist_formfields .addRuleRow:hover,.fieldlist_formfields .removeRuleRow:hover,.fieldlist_formfields .verifyRuleRow:hover{background-color:#999}.watch-table.favicon-not-enabled tr .favicon{display:none}.watch-table tr td.inline.title-col .flex-wrapper{display:flex;align-items:center;gap:4px}.watch-table td,.watch-table th{vertical-align:middle}.watch-table tr.has-favicon.unviewed img.favicon{opacity:1 !important}.watch-table .status-icons{white-space:nowrap;display:flex;align-items:center;gap:4px}.watch-table .status-icons>*{vertical-align:middle}.title-col{padding:10px}.title-wrapper{display:flex;align-items:center;gap:10px}.title-col-inner{display:inline-block;vertical-align:middle}.watch-table img.favicon{vertical-align:middle;max-width:25px;max-height:25px;height:25px;padding-right:4px}body.checking-now #checking-now-fixed-tab{display:block !important}#checking-now-fixed-tab{background:#ccc;border-radius:5px;bottom:0;color:var(--color-text);display:none;font-size:.8rem;left:0;padding:5px;position:fixed}#selector-wrapper{height:100%;text-align:center;max-height:70vh;overflow-y:scroll;position:relative}#selector-wrapper>img{position:absolute;z-index:4;max-width:100%}#selector-wrapper>canvas{position:relative;z-index:5;max-width:100%}#selector-wrapper>canvas:hover{cursor:pointer}#selector-current-xpath{font-size:80%}.ternary-radio-group{display:flex;gap:0;border:1px solid var(--color-grey-750);border-radius:4px;overflow:hidden;width:fit-content;background:var(--color-background)}.ternary-radio-group .ternary-radio-option{position:relative;cursor:pointer;margin:0;display:flex;align-items:center}.ternary-radio-group .ternary-radio-option input[type=radio]{position:absolute;opacity:0;width:0;height:0}.ternary-radio-group .ternary-radio-option .ternary-radio-label{padding:8px 16px;background:var(--color-grey-900);border:none;border-right:1px solid var(--color-grey-750);font-size:13px;font-weight:500;color:var(--color-text);transition:all .2s ease;cursor:pointer;display:block;text-align:center}.ternary-radio-group .ternary-radio-option:last-child .ternary-radio-label{border-right:none}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button);font-weight:600}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600);color:var(--color-text-button)}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}.ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-800)}@media(max-width: 480px){.ternary-radio-group{width:100%}.ternary-radio-group .ternary-radio-label{flex:1;min-width:auto}}input[type=radio].pure-radio:checked+label,input[type=radio].pure-radio:checked{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option .ternary-radio-label{background:var(--color-grey-350)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-400)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}.notifications-dashboard h4{margin-top:0}.notifications-dashboard .last-success-box{background:#d4edda;border:1px solid #c3e6cb;border-radius:5px;padding:15px;margin-bottom:20px}.notifications-dashboard .last-success-box h5{margin-top:0;color:#155724}.notifications-dashboard .last-success-box .details{font-size:90%}.notifications-dashboard .last-success-box .details>div{margin-bottom:5px}.notifications-dashboard .last-success-box .details code{background:#fff;padding:2px 6px;border-radius:3px;margin-right:5px;font-size:85%}.notifications-dashboard .last-success-box details{margin-top:10px}.notifications-dashboard .last-success-box details summary{cursor:pointer;font-weight:bold;font-size:90%;color:#155724}.notifications-dashboard .last-success-box details pre{background:#fff;padding:10px;border-radius:3px;border:1px solid #c3e6cb;margin:10px 0 0 0;white-space:pre-wrap;word-wrap:break-word;font-size:80%;color:#333;max-height:300px;overflow-y:auto}.notifications-dashboard .last-success-box .note{font-size:85%;color:#155724;margin:10px 0 0 0;font-style:italic}.notifications-dashboard .dashboard-actions{margin-bottom:20px;display:flex;gap:10px;flex-wrap:wrap}.notifications-dashboard .dashboard-actions form{display:inline-block;margin:0}.notifications-dashboard .dashboard-actions .retry-all-btn{background:#28a745;font-size:90%}.notifications-dashboard .dashboard-actions .clear-all-btn{background:#6c757d;color:#fff;font-size:90%}.notifications-dashboard .dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px}@media(max-width: 768px){.notifications-dashboard .dashboard-grid{grid-template-columns:1fr !important}}.notifications-dashboard .pending-column{background:#f8f9fa;border:2px solid #06c;border-radius:8px;padding:20px}.notifications-dashboard .failed-column{background:#fff3f3;border:2px solid #dc3545;border-radius:8px;padding:20px}.notifications-dashboard .column-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px}.notifications-dashboard .column-header h5{margin:0}.notifications-dashboard .column-header .count-badge{color:#fff;padding:4px 12px;border-radius:12px;font-weight:bold;font-size:90%}.notifications-dashboard .pending-column .column-header h5{color:#06c}.notifications-dashboard .pending-column .column-header .count-badge{background:#06c}.notifications-dashboard .failed-column .column-header h5{color:#dc3545}.notifications-dashboard .failed-column .column-header .count-badge.has-failures{background:#dc3545}.notifications-dashboard .failed-column .column-header .count-badge.no-failures{background:#28a745}.notifications-dashboard .column-description{font-size:85%;color:#666;margin:0 0 15px 0}.notifications-dashboard .notification-cards{max-height:400px;overflow-y:auto}.notifications-dashboard .notification-card{background:#fff;border-radius:5px;padding:12px;margin-bottom:10px}.notifications-dashboard .notification-card.pending-card{border:1px solid #dee2e6}.notifications-dashboard .notification-card.failed-card{border:1px solid #dc3545}.notifications-dashboard .card-header{display:flex;align-items:center;margin-bottom:8px}.notifications-dashboard .card-header .status-badge{color:#fff;padding:2px 8px;border-radius:3px;font-size:75%;font-weight:bold;margin-right:8px}.notifications-dashboard .card-header .status-badge.queued{background:#ffc107}.notifications-dashboard .card-header .status-badge.retrying{background:#06c}.notifications-dashboard .card-header .status-badge.failed{background:#dc3545}.notifications-dashboard .card-header a{font-weight:bold;color:#333}.notifications-dashboard .notification-id{font-size:75%;color:#999;margin-bottom:5px;font-family:monospace}.notifications-dashboard .notification-queued-time{font-size:80%;color:#999;margin-bottom:5px}.notifications-dashboard .notification-target{font-size:85%;color:#666;margin-bottom:5px;word-break:break-all}.notifications-dashboard .notification-endpoints{font-size:85%;color:#666;margin-bottom:8px}.notifications-dashboard .notification-endpoints .endpoint-item{margin-left:10px;font-family:monospace;font-size:80%}.notifications-dashboard .retry-info{font-size:80%;color:#999;display:flex;align-items:center;justify-content:space-between}.notifications-dashboard .retry-info form{display:inline;margin-left:10px}.notifications-dashboard .retry-info .send-now-btn{font-size:75%;padding:2px 6px;background:#17a2b8;color:#fff}.notifications-dashboard .failure-time{font-size:80%;color:#999;margin-bottom:8px}.notifications-dashboard .retry-form{margin-top:10px}.notifications-dashboard .retry-form button{font-size:85%;padding:5px 10px}.notifications-dashboard .log-info-box{background:#f5f5f5;border:1px solid #ddd;border-radius:5px;padding:15px;margin-bottom:20px}.notifications-dashboard .log-info-box .log-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;border-bottom:1px solid #ddd;padding-bottom:10px}.notifications-dashboard .log-info-box .log-header h5{margin:0;color:#333}.notifications-dashboard .log-info-box .log-header .close-btn{background:rgba(0,0,0,0);border:none;font-size:20px;cursor:pointer;padding:0;width:25px;height:25px;line-height:25px;text-align:center;color:#666}.notifications-dashboard .log-info-box .log-header .close-btn:hover{color:#000}.notifications-dashboard .log-info-box .log-content .log-meta{margin-bottom:15px;font-size:90%}.notifications-dashboard .log-info-box .log-content .log-meta>div{margin-bottom:5px}.notifications-dashboard .log-info-box .log-content .log-meta strong{color:#555}.notifications-dashboard .log-info-box .log-content .log-meta .notification-id{font-family:monospace;font-size:85%;color:#666}.notifications-dashboard .log-info-box .log-content .log-meta .endpoint-item{margin-left:10px;font-family:monospace;font-size:85%}.notifications-dashboard .log-info-box .log-content .log-body h6{margin:10px 0 5px 0;color:#555}.notifications-dashboard .log-info-box .log-content .log-body pre{background:#fff;border:1px solid #ddd;border-radius:3px;padding:10px;font-size:80%;max-height:400px;overflow-y:auto;white-space:pre-wrap;word-wrap:break-word;margin:0;color:#333}.notifications-dashboard .log-info-box .log-content .log-error{margin-top:15px;padding-top:15px;border-top:1px solid #ddd}.notifications-dashboard .log-info-box .log-content .log-error h6{margin:0 0 5px 0;color:#dc3545}.notifications-dashboard .log-info-box .log-content .log-error pre{background:#fff3f3;border:1px solid #ffcdd2;border-radius:3px;padding:10px;font-size:80%;max-height:200px;overflow-y:auto;white-space:pre-wrap;word-wrap:break-word;margin:0;color:#c62828}.notifications-dashboard .empty-state{background:#fff;border-radius:5px;padding:20px;text-align:center}.notifications-dashboard .empty-state.pending-empty{border:1px solid #dee2e6;color:#999}.notifications-dashboard .empty-state.failed-empty{border:1px solid #28a745;color:#28a745}.notifications-dashboard .retry-schedule{background:#f8f9fa;border:1px solid #dee2e6;border-radius:5px;padding:15px;margin-bottom:20px}.notifications-dashboard .retry-schedule h5{margin-top:0}.notifications-dashboard .retry-schedule p{font-size:90%;margin-bottom:10px}.notifications-dashboard .retry-schedule table{width:100%;font-size:90%}.notifications-dashboard .retry-schedule .note{font-size:85%;margin-top:10px;margin-bottom:0;color:#666}@media(prefers-color-scheme: dark){.notifications-dashboard{background:#1e1e1e;color:#e0e0e0}.notifications-dashboard h4,.notifications-dashboard h5{color:#e0e0e0}.notifications-dashboard .last-success-box{background:#1a3a2a;border-color:#2d5a3d}.notifications-dashboard .last-success-box h5{color:#66bb6a}.notifications-dashboard .last-success-box .details code{background:#2d2d2d;color:#e0e0e0}.notifications-dashboard .last-success-box details pre{background:#2d2d2d;border-color:#2d5a3d;color:#e0e0e0}.notifications-dashboard .last-success-box .note{color:#81c784}.notifications-dashboard .dashboard-grid .pending-column{background:#252525;border-color:#3a7bc8}.notifications-dashboard .dashboard-grid .failed-column{background:#2a2020;border-color:#c84040}.notifications-dashboard .notification-card{background:#2d2d2d;border-color:#444}.notifications-dashboard .notification-card .card-header a{color:#90caf9}.notifications-dashboard .notification-card.failed-card{border-color:#c84040}.notifications-dashboard .notification-id,.notifications-dashboard .notification-queued-time,.notifications-dashboard .notification-target,.notifications-dashboard .notification-endpoints,.notifications-dashboard .failure-time{color:#aaa}.notifications-dashboard .column-description{color:#999}.notifications-dashboard .empty-state{background:#2d2d2d;color:#999}.notifications-dashboard .empty-state.failed-empty{border-color:#66bb6a;color:#66bb6a}.notifications-dashboard .log-info-box{background:#252525;border-color:#444}.notifications-dashboard .log-info-box .log-header{border-bottom-color:#444}.notifications-dashboard .log-info-box .log-header h5{color:#e0e0e0}.notifications-dashboard .log-info-box .log-header .close-btn{color:#aaa}.notifications-dashboard .log-info-box .log-header .close-btn:hover{color:#fff}.notifications-dashboard .log-info-box .log-content .log-meta strong{color:#bbb}.notifications-dashboard .log-info-box .log-content .log-meta .notification-id,.notifications-dashboard .log-info-box .log-content .log-meta .endpoint-item{color:#aaa}.notifications-dashboard .log-info-box .log-content .log-body h6{color:#bbb}.notifications-dashboard .log-info-box .log-content .log-body pre{background:#1e1e1e;border-color:#444;color:#e0e0e0}.notifications-dashboard .log-info-box .log-content .log-error{border-top-color:#444}.notifications-dashboard .log-info-box .log-content .log-error h6{color:#ef5350}.notifications-dashboard .log-info-box .log-content .log-error pre{background:#2a1f1f;border-color:#c84040;color:#ef9a9a}.notifications-dashboard .retry-schedule{background:#252525;border-color:#444;color:#e0e0e0}.notifications-dashboard .retry-schedule .note{color:#aaa}}body{color:var(--color-text);background:var(--color-background-page);font-family:Helvetica Neue,Helvetica,Lucida Grande,Arial,Ubuntu,Cantarell,Fira Sans,sans-serif}.visually-hidden{clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}.status-icon{display:inline-block;height:1rem;vertical-align:middle}.pure-table-even{background:var(--color-background)}a{text-decoration:none;color:var(--color-link)}a.github-link{color:var(--color-icon-github);margin:0 1rem 0 .5rem}a.github-link svg{fill:currentColor}a.github-link:hover{color:var(--color-icon-github-hover)}#search-q{opacity:0;-webkit-transition:all .9s ease;-moz-transition:all .9s ease;transition:all .9s ease;width:0;display:none}#search-q.expanded{width:auto;display:inline-block;opacity:1}#search-result-info{color:#fff}button.toggle-button{vertical-align:middle;background:rgba(0,0,0,0);border:none;cursor:pointer;color:var(--color-icon-github)}button.toggle-button:hover{color:var(--color-icon-github-hover)}button.toggle-button svg{fill:currentColor}button.toggle-button .icon-light{display:block}.pure-menu-horizontal{background:var(--color-background);padding:5px;display:flex;justify-content:space-between;align-items:center}#pure-menu-horizontal-spinner{height:3px;background:linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);background-size:400% 400%;width:100%;animation:gradient 200s ease infinite}body.spinner-active #pure-menu-horizontal-spinner{animation:gradient 1s ease infinite}@keyframes gradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}.pure-menu-heading{color:var(--color-text-menu-heading)}.pure-menu-link{color:var(--color-text-menu-link)}.pure-menu-link:hover{background-color:var(--color-background-menu-link-hover);color:var(--color-text-menu-link-hover)}.tab-pane-inner{scroll-margin-top:200px}section.content{padding-top:100px;padding-bottom:1em;flex-direction:column;display:flex;align-items:center;justify-content:center}code{background:var(--color-background-code);color:var(--color-text)}.inline-tag,.restock-label,.tracking-ldjson-price-data,.watch-tag-list{white-space:nowrap;border-radius:5px;padding:2px 5px;margin-right:4px}.watch-tag-list{color:var(--color-white);background:var(--color-text-watch-tag-list)}@media(min-width: 768px){.box{margin:0 1em !important}}.box{max-width:100%;margin:0 .3em;flex-direction:column;display:flex;justify-content:center}body:after{content:"";background:linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%)}body:after,body:before{display:block;height:650px;position:absolute;top:0;left:0;width:100%;z-index:-1}body::after{opacity:.91}body::before{content:""}body:after,body:before{-webkit-clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%);clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%)}.button-small{font-size:85%}.button-xsmall{font-size:70%}.fetch-error{padding-top:1em;font-size:80%;max-width:400px;display:block}.pure-button-primary,a.pure-button-primary,.pure-button-selected,a.pure-button-selected{background-color:var(--color-background-button-primary)}.button-secondary{color:var(--color-text-button);border-radius:4px;text-shadow:0 1px 1px rgba(0,0,0,.2)}.button-success{background:var(--color-background-button-success)}.button-tag{background:var(--color-background-button-tag);color:var(--color-text-button);font-size:65%;border-bottom-left-radius:initial;border-bottom-right-radius:initial;margin-right:4px}.button-tag.active{background:var(--color-background-button-tag-active);font-weight:bold}.button-error{background:var(--color-background-button-error);color:var(--color-text-button-error)}.button-warning{background:var(--color-background-button-warning);color:var(--color-text-button-warning)}.button-secondary{background:var(--color-background-button-secondary)}.button-cancel{background:var(--color-background-button-cancel)}.messages li{list-style:none;padding:1em;border-radius:10px;color:var(--color-text-messages);font-weight:bold}.messages li.message{background:var(--color-background-messages-message)}.messages li.error{background:var(--color-background-messages-error)}.messages li.notice{background:var(--color-background-messages-notice)}.messages.with-share-link>*:hover{cursor:pointer}.notifications-wrapper{padding-top:.5rem}.notifications-wrapper #notification-test-log{margin-top:1rem;padding:1rem;white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word;max-width:100%;box-sizing:border-box;max-height:12rem;overflow-y:scroll;border:1px solid var(--color-border-notification);border-radius:5px}label:hover{cursor:pointer}.grey-form-border{border:1px solid var(--color-border-notification);padding:.5rem;border-radius:5px}#notification-error-log{border:1px solid var(--color-border-notification);padding:1rem;border-radius:5px;overflow-wrap:break-word}#token-table.pure-table td,#token-table.pure-table th{font-size:80%}.pure-form input[type=text].transparent-field{background-color:var(--color-background-new-watch-input-transparent) !important;color:var(--color-white) !important;border:1px solid hsla(0,0%,100%,.2) !important;box-shadow:none !important;-webkit-box-shadow:none !important}.pure-form input[type=text].transparent-field::placeholder{opacity:.5;color:hsla(0,0%,100%,.7);font-weight:lighter}#new-watch-form{background:var(--color-background-new-watch-form);padding:1em;border-radius:10px;margin-bottom:1em;max-width:100%}#new-watch-form #url::placeholder{font-weight:bold}#new-watch-form input{display:inline-block;margin-bottom:5px}#new-watch-form input:not(.pure-button){background-color:var(--color-background-new-watch-input);color:var(--color-text-new-watch-input)}#new-watch-form .label{display:none}#new-watch-form legend{color:var(--color-text-legend);font-weight:bold}@media only screen and (min-width: 760px){#new-watch-form #watch-add-wrapper-zone{display:flex;gap:.3rem;flex-direction:row;min-width:70vw}}#new-watch-form #watch-add-wrapper-zone>span{flex-grow:0}#new-watch-form #watch-add-wrapper-zone>span input{width:100%;padding-right:1em}#new-watch-form #watch-add-wrapper-zone>span:first-child{flex-grow:1}@media only screen and (max-width: 760px){#new-watch-form #watch-add-wrapper-zone #url{width:100%}}#new-watch-form #watch-group-tag{font-size:.9rem;padding:.3rem;display:flex;align-items:center;gap:.5rem;color:var(--color-white)}#new-watch-form #watch-group-tag label,#new-watch-form #watch-group-tag input{margin:0}#new-watch-form #watch-group-tag input{flex:1}#diff-col{padding-left:40px}#diff-jump{position:fixed;left:0px;top:120px;background:var(--color-background);padding:10px;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}#diff-jump a{color:var(--color-link);cursor:pointer;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;-o-user-select:none}footer{padding:10px;background:var(--color-background);color:var(--color-text-footer);text-align:center}#feed-icon{vertical-align:middle}.sticky-tab{position:absolute;top:60px;font-size:65%;background:var(--color-background);padding:10px}.sticky-tab#left-sticky{left:0;position:fixed;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}.sticky-tab#right-sticky{right:0px}.sticky-tab#hosted-sticky{right:0px;top:100px;font-weight:bold}#new-version-text a{color:var(--color-link-new-version)}.watch-controls{color:#f8321b}.watch-controls .state-on img{opacity:.8}.watch-controls img{opacity:.2}.watch-controls img:hover{transition:opacity .3s;opacity:.8}.monospaced-textarea textarea{width:100%;font-family:monospace;white-space:pre;overflow-wrap:normal;overflow-x:auto}.pure-form fieldset{padding-top:0px}.pure-form fieldset ul{padding-bottom:0px;margin-bottom:0px}.pure-form .pure-control-group,.pure-form .pure-group,.pure-form .pure-controls{padding-bottom:1em}.pure-form .pure-control-group div,.pure-form .pure-group div,.pure-form .pure-controls div{margin:0px}.pure-form .pure-control-group .checkbox>*,.pure-form .pure-group .checkbox>*,.pure-form .pure-controls .checkbox>*{display:inline;vertical-align:middle}.pure-form .pure-control-group .checkbox>label,.pure-form .pure-group .checkbox>label,.pure-form .pure-controls .checkbox>label{padding-left:5px}.pure-form .pure-control-group legend,.pure-form .pure-group legend,.pure-form .pure-controls legend{color:var(--color-text-legend)}.pure-form .error input{background-color:var(--color-error-input)}.pure-form ul.errors{padding:.5em .6em;border:1px solid var(--color-error-list);border-radius:4px;vertical-align:middle;-webkit-box-sizing:border-box;box-sizing:border-box}.pure-form ul.errors li{margin-left:1em;color:var(--color-error-list)}.pure-form label{font-weight:bold}.pure-form textarea{width:100%}.pure-form .inline-radio ul{margin:0px;list-style:none}.pure-form .inline-radio ul li{display:flex;align-items:center;gap:1em}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 1024px){.edit-form{padding:.5em;margin:0}#nav-menu{overflow-x:scroll}}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 800px){div.sticky-tab#hosted-sticky{top:60px;left:0px;right:auto}section.content{padding-top:110px}div.tabs.collapsable ul li{display:block;border-radius:0px;margin-right:0px}input[type=text]{width:100%}}.pure-table{border-color:var(--color-border-table-cell)}.pure-table thead{background-color:var(--color-background-table-thead);color:var(--color-text);border-bottom:1px solid var(--color-background-table-thead)}.pure-table td,.pure-table th{border-left-color:var(--color-border-table-cell)}.pure-table-striped tr:nth-child(2n-1) td{background-color:var(--color-table-stripe)}.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form select,.pure-form textarea{border:var(--color-border-input);box-shadow:inset 0 1px 3px var(--color-shadow-input);background-color:var(--color-background-input);color:var(--color-text-input)}.pure-form input[type=color]:active,.pure-form input[type=date]:active,.pure-form input[type=datetime-local]:active,.pure-form input[type=datetime]:active,.pure-form input[type=email]:active,.pure-form input[type=month]:active,.pure-form input[type=number]:active,.pure-form input[type=password]:active,.pure-form input[type=search]:active,.pure-form input[type=tel]:active,.pure-form input[type=text]:active,.pure-form input[type=time]:active,.pure-form input[type=url]:active,.pure-form input[type=week]:active,.pure-form select:active,.pure-form textarea:active{background-color:var(--color-background-input)}input::placeholder,textarea::placeholder{color:var(--color-text-input-placeholder)}.m-d{min-width:100%}@media only screen and (min-width: 761px){.m-d{min-width:80%}}.tabs ul{margin:0px;padding:0px;display:block}.tabs ul li{margin-right:3px;display:inline-block;color:var(--color-text-tab);border-top-left-radius:5px;border-top-right-radius:5px;background-color:var(--color-background-tab)}.tabs ul li:not(.active):hover{background-color:var(--color-background-tab-hover)}.tabs ul li.active,.tabs ul li :target{background-color:var(--color-background)}.tabs ul li.active a,.tabs ul li :target a{color:var(--color-text-tab-active);font-weight:bold}.tabs ul li a{display:block;padding:.8em;color:var(--color-text-tab)}.pure-form-stacked>div:first-child{display:block}.login-form .inner{background:var(--color-background);padding:20px;border-radius:5px}.tab-pane-inner{padding:0px}.tab-pane-inner:not(:target){display:none}.tab-pane-inner:target{display:block}.beta-logo{height:50px;right:-3px;top:-3px;position:absolute}#selector-header{padding-bottom:1em}body.full-width .edit-form{width:95%}.edit-form{min-width:70%;max-width:95%}.edit-form .box-wrap{position:relative}.edit-form .inner{background:var(--color-background);padding:20px}.edit-form #actions{display:block;background:var(--color-background)}.edit-form #actions .pure-control-group{display:flex;gap:.625em;flex-wrap:wrap}.edit-form .pure-form-message-inline{padding-left:0;color:var(--color-text-input-description)}.edit-form .pure-form-message-inline code{font-size:.875em}.border-fieldset{border:1px solid #ccc;padding:1rem;border-radius:5px;margin-bottom:1rem}.border-fieldset h3{margin-top:0}.border-fieldset fieldset:last-of-type{padding-bottom:0}.border-fieldset fieldset:last-of-type .pure-control-group{padding-bottom:0}ul{padding-left:1em;padding-top:0px;margin-top:4px}.time-check-widget tr{display:inline}.time-check-widget tr input[type=number]{width:5em}@media only screen and (max-width: 760px){.time-check-widget tbody{display:grid;grid-template-columns:auto 1fr auto 1fr;gap:.625em .3125em;align-items:center}.time-check-widget tr{display:contents}.time-check-widget tr th{text-align:right;padding-right:5px}.time-check-widget tr input[type=number]{width:100%;max-width:5em}}#webdriver_delay{width:5em}#api-key:hover{cursor:pointer}#api-key-copy{color:var(--color-api-key)}.button-green{background-color:var(--color-background-button-green)}.button-red{background-color:var(--color-background-button-red)}.noselect{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.snapshot-age{padding:4px;margin:.5rem 0;background-color:var(--color-background-snapshot-age);border-radius:3px;font-weight:bold;margin-bottom:4px}.snapshot-age.error{background-color:var(--color-error-background-snapshot-age);color:var(--color-error-text-snapshot-age)}#checkbox-operations{background:var(--color-background-checkbox-operations);padding:1em;border-radius:10px;margin-bottom:1em;display:none}#checkbox-operations button{margin-bottom:3px;margin-top:3px;display:inline-flex;align-items:center}.checkbox-uuid>*{vertical-align:middle}.inline-warning{border:1px solid var(--color-border-warning);padding:.5rem;border-radius:5px;color:var(--color-warning)}.inline-warning>span{display:inline-block;vertical-align:middle}.inline-warning img.inline-warning-icon{display:inline;height:26px;vertical-align:middle}.tracking-ldjson-price-data{background-color:var(--color-background-button-green);color:#000;opacity:.6}.ldjson-price-track-offer{font-weight:bold;font-style:italic}.ldjson-price-track-offer a.pure-button{border-radius:3px;padding:3px;background-color:var(--color-background-button-green)}.price-follow-tag-icon{display:inline-block;height:.8rem;vertical-align:middle}#quick-watch-processor-type ul#processor{color:#fff;padding-left:0px}#quick-watch-processor-type ul#processor li{list-style:none;font-size:.9rem;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:.5rem;margin-bottom:.5rem}#quick-watch-processor-type label,#quick-watch-processor-type input{padding:0;margin:0}.restock-label.in-stock{background-color:var(--color-background-button-green);color:#fff}.restock-label.not-in-stock{background-color:var(--color-background-button-cancel);color:#777}.restock-label.error{background-color:var(--color-background-button-error);color:#fff;opacity:.7}.restock-label svg{vertical-align:middle}#chrome-extension-link{padding:9px;border:1px solid var(--color-grey-800);border-radius:10px;vertical-align:middle}#chrome-extension-link img{height:21px;padding:2px;vertical-align:middle}#realtime-conn-error{position:fixed;bottom:0;left:0;background:var(--color-warning);padding:10px;font-size:.8rem;color:#fff;opacity:.8}#failed-notifications-icon{display:none}body.failed-notifications #failed-notifications-icon{display:inline-block;vertical-align:middle} diff --git a/changedetectionio/templates/_common_fields.html b/changedetectionio/templates/_common_fields.html index 1f3e61fc7..e01fbef31 100644 --- a/changedetectionio/templates/_common_fields.html +++ b/changedetectionio/templates/_common_fields.html @@ -141,7 +141,7 @@ Add email Add an email address {% endif %} Notification debug logs - Failed notifications + Notification Queue
diff --git a/changedetectionio/templates/base.html b/changedetectionio/templates/base.html index c5f9076d7..d15466308 100644 --- a/changedetectionio/templates/base.html +++ b/changedetectionio/templates/base.html @@ -86,8 +86,8 @@ BACKUPS
  • - - Failed notifications + + Notification Queue
  • {% else %} diff --git a/changedetectionio/tests/test_notification_errors.py b/changedetectionio/tests/test_notification_errors.py index f09fdc9a9..0eea26f8c 100644 --- a/changedetectionio/tests/test_notification_errors.py +++ b/changedetectionio/tests/test_notification_errors.py @@ -3,6 +3,12 @@ import time from flask import url_for from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks import logging +import pytest + +# Set environment variable at module level to disable retries for dead-letter test +# This must be done BEFORE the Flask app/Huey is initialized +_original_retry_count = os.environ.get('NOTIFICATION_RETRY_COUNT') +os.environ['NOTIFICATION_RETRY_COUNT'] = '0' def test_check_notification_error_handling(client, live_server, measure_memory_usage, datastore_path): diff --git a/changedetectionio/tests/test_notifications_huey.py b/changedetectionio/tests/test_notifications_huey.py new file mode 100644 index 000000000..393d35ccf --- /dev/null +++ b/changedetectionio/tests/test_notifications_huey.py @@ -0,0 +1,859 @@ +import os +import time +from flask import url_for +from .util import set_original_response, set_modified_response, wait_for_all_checks +import logging + +# Set environment variables at module level for fast Huey retry testing +# Use 1 retry with 10 second delay (minimum allowed) to test retry mechanism +os.environ['NOTIFICATION_RETRY_COUNT'] = '1' +os.environ['NOTIFICATION_RETRY_DELAY'] = '10' + + +def test_notification_dead_letter_retry(client, live_server, measure_memory_usage, datastore_path): + """ + Test that failed notifications appear in dead-letter queue and can be retried. + + Steps: + 1. Create a watch with a broken notification URL + 2. Trigger a notification that will fail + 3. Verify the notification appears in the dead-letter queue after retries are exhausted + 4. Fix the notification URL + 5. Retry all dead-letter notifications + 6. Verify the dead-letter queue is empty after successful retry + + Note: This test uses NOTIFICATION_RETRY_COUNT=1 with NOTIFICATION_RETRY_DELAY=3s + to test the retry mechanism while keeping test execution fast (~6 seconds total). + """ + from changedetectionio.notification.task_queue import get_failed_notifications, retry_all_failed_notifications + + set_original_response(datastore_path=datastore_path) + + # Set a URL and fetch it + test_url = url_for('test_endpoint', _external=True) + uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) + + wait_for_all_checks(client) + + # Set a broken notification URL that will definitely fail + broken_notification_url = "jsons://broken-url-xxxxxxxx-will-fail-456/test" + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "notification_urls": broken_notification_url, + "notification_title": "Test Dead Letter", + "notification_body": "This should fail and go to dead letter queue", + "notification_format": 'text', + "url": test_url, + "tags": "", + "title": "", + "headers": "", + "time_between_check-minutes": "180", + "fetch_backend": "html_requests", + "time_between_check_use_default": "y" + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + wait_for_all_checks(client) + set_modified_response(datastore_path=datastore_path) + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + # Verify that task metadata is being stored (required for dead-letter queue) + from changedetectionio.notification.task_queue import huey + if huey and hasattr(huey, 'storage'): + storage_path = getattr(huey.storage, 'path', None) + if storage_path: + metadata_dir = os.path.join(storage_path, 'task_metadata') + # Give it a moment for the metadata to be written + time.sleep(1) + assert os.path.exists(metadata_dir), \ + f"Task metadata directory should exist at {metadata_dir} for dead-letter queue to work" + + # Wait for notification to fail and exhaust retries + # With 1 retry and 3 second delay: initial attempt + 3s wait + 1 retry = ~6 seconds total + # Add extra time for Huey to write the result to storage + max_wait_time = 20 # Allow buffer time for storage + start_time = time.time() + failed_found = False + + logging.info("Waiting for notification to fail and exhaust retries...") + + while time.time() - start_time < max_wait_time: + # Check if notification has failed and is in dead-letter queue + failed_notifications = get_failed_notifications() + + elapsed = time.time() - start_time + logging.debug(f"Time elapsed: {elapsed:.1f}s, Failed notifications: {len(failed_notifications)}") + + if failed_notifications and len(failed_notifications) > 0: + # Found at least one failed notification + failed_found = True + logging.info(f"Found {len(failed_notifications)} failed notification(s) in dead-letter queue after {elapsed:.1f}s") + + # Verify it's our notification + assert any('broken-url-xxxxxxxx-will-fail-456' in str(notif.get('notification_data', {})) + for notif in failed_notifications), "Failed notification should contain our broken URL" + break + + time.sleep(1) # Check every second + + assert failed_found, "Notification should have failed and appeared in dead-letter queue" + + # Get the count of failed notifications before retry + failed_before = get_failed_notifications() + failed_count_before = len(failed_before) + assert failed_count_before > 0, "Should have at least one failed notification before retry" + + logging.info(f"Dead-letter queue has {failed_count_before} failed notification(s) before retry") + + # Fix the notification URL before retrying so the retry will succeed + # Use a working notification URL that will succeed + working_notification_url = url_for('test_notification_endpoint', _external=True).replace('http', 'json') + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "notification_urls": working_notification_url, + "notification_title": "Test Dead Letter - Fixed", + "notification_body": "This should succeed after retry", + "notification_format": 'text', + "url": test_url, + "tags": "", + "title": "", + "headers": "", + "time_between_check-minutes": "180", + "fetch_backend": "html_requests", + "time_between_check_use_default": "y" + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + logging.info("Updated notification URL to working URL before retry") + + # Now retry all failed notifications + retry_result = retry_all_failed_notifications() + + logging.info(f"Retry result: {retry_result}") + assert retry_result['total'] > 0, "Should have attempted to retry at least one notification" + assert retry_result['success'] > 0, "At least one retry should have succeeded" + + # Give it time for the retry to process and succeed + time.sleep(3) + + # Check that dead-letter queue is now empty + failed_after = get_failed_notifications() + failed_count_after = len(failed_after) + + logging.info(f"Dead-letter queue has {failed_count_after} failed notification(s) after retry") + + # The dead-letter queue should be empty after successful retry + assert failed_count_after == 0, \ + f"Dead-letter queue should be empty after retry (before: {failed_count_before}, after: {failed_count_after})" + + # Verify the notification was actually sent successfully + notification_file = os.path.join(datastore_path, "notification.txt") + assert os.path.exists(notification_file), "Notification file should exist after successful retry" + + with open(notification_file, "r") as f: + notification_content = f.read() + + # The notification should contain the original message (body is preserved from original notification) + # But the notification_urls were reloaded from current settings (the working URL) + assert 'This should fail and go to dead letter queue' in notification_content, \ + "Notification should contain the original message (body is preserved during retry)" + + os.unlink(notification_file) + + logging.info("✓ Dead-letter retry test completed successfully") + + +def test_notification_dead_letter_ui_and_utilities(client, live_server, measure_memory_usage, datastore_path): + """ + Test dead-letter queue UI integration and utility functions. + + This test verifies: + 1. Failed notifications appear in the settings/failed-notifications page + 2. The body class "failed-notifications" is added when there are failures + 3. Storage counting functions work correctly + 4. Cleanup functions work correctly + 5. Clear all notifications function works correctly + """ + from changedetectionio.notification.task_queue import ( + get_failed_notifications, + get_pending_notifications_count, + cleanup_old_failed_notifications, + clear_all_notifications, + retry_all_failed_notifications + ) + from changedetectionio.notification.task_queue import huey + + set_original_response(datastore_path=datastore_path) + + # Set a URL and fetch it + test_url = url_for('test_endpoint', _external=True) + uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) + + wait_for_all_checks(client) + + # Set a broken notification URL that will definitely fail + broken_notification_url = "jsons://broken-url-test-ui-12345/test" + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "notification_urls": broken_notification_url, + "notification_title": "Test UI Integration", + "notification_body": "This notification will fail for UI testing", + "notification_format": 'text', + "url": test_url, + "tags": "", + "title": "", + "headers": "", + "time_between_check-minutes": "180", + "fetch_backend": "html_requests", + "time_between_check_use_default": "y" + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + wait_for_all_checks(client) + set_modified_response(datastore_path=datastore_path) + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + # Test pending notifications count (should include the queued notification) + pending_count = get_pending_notifications_count() + logging.info(f"Pending notifications count: {pending_count}") + assert pending_count >= 0, "Should be able to get pending notifications count" + + # Wait for notification to fail and exhaust ALL retries + # With 1 retry and 3s delay: initial attempt + 3s + retry = ~4 seconds + # But we need to wait for the retry to complete and result to be stored + max_wait_time = 20 + start_time = time.time() + failed_found = False + + logging.info("Waiting for notification to fail and exhaust all retries...") + + while time.time() - start_time < max_wait_time: + failed_notifications = get_failed_notifications() + if failed_notifications and len(failed_notifications) > 0: + failed_found = True + logging.info(f"Found {len(failed_notifications)} failed notification(s) after {time.time() - start_time:.1f}s") + # Wait a bit more to ensure the notification is fully processed + # and not in the middle of a retry + time.sleep(2) + break + time.sleep(1) + + assert failed_found, "Notification should have failed and appeared in dead-letter queue" + + # Test 1: Check the settings/failed-notifications page is accessible + logging.info("Testing settings/failed-notifications page...") + res = client.get(url_for("settings.failed_notifications")) + assert res.status_code == 200, "Failed notifications page should be accessible" + # The page should show that there's 1 failed notification in the Clear All dialog + assert b"1 Failed" in res.data or b"1 failed" in res.data, "Page should show failed notification count" + # The page should have Retry All and Clear All buttons + assert b"Retry All" in res.data, "Page should have Retry All button" + assert b"Clear All" in res.data, "Page should have Clear All button" + + # Verify both notification sections are present in the page structure + assert b"Pending / Retrying" in res.data, "Page should show Pending / Retrying section" + assert b"Failed (Dead Letter)" in res.data, "Page should show Failed (Dead Letter) section" + # The pending/retrying count shows "0 Pending/Retrying" in the Clear All button + assert b"0 Pending/Retrying" in res.data or b"0 pending" in res.data.lower(), \ + "Page should show pending/retrying count (0 in this case)" + # Check that failed notifications list is present by verifying FAILED badge and Retry This button + assert b"FAILED" in res.data, "Page should show FAILED status badge for failed notification" + assert b"Retry This" in res.data, "Page should show 'Retry This' button for failed notification" + # The template has a
    element for pending notifications (only shown when pending_list exists) + # In this test, all retries are exhausted, so pending_list is empty and details won't render + # But we can verify the page has the proper structure by checking Clear All shows "0 Pending" + + logging.info("✓ Failed notifications page is accessible, shows both pending/retrying and failed sections") + + # Test 2: Verify body class "failed-notifications" is present + logging.info("Testing body class for failed notifications...") + res = client.get(url_for("watchlist.index")) + assert res.status_code == 200 + assert b' 0, "Should have failed notifications before clearing" + + # Test 5: Clear all notifications + logging.info("Testing clear_all_notifications...") + clear_result = clear_all_notifications() + logging.info(f"Clear result: {clear_result}") + assert 'results' in clear_result, "Clear result should include 'results' key" + assert clear_result['results'] > 0, "Should have cleared at least one result" + logging.info("✓ clear_all_notifications works correctly") + + # Test 6: Verify dead-letter queue is empty after clearing + logging.info("Verifying dead-letter queue is empty after clear...") + failed_after_clear = get_failed_notifications() + assert len(failed_after_clear) == 0, "Dead-letter queue should be empty after clearing" + logging.info("✓ Dead-letter queue is empty after clear") + + # Test 7: Verify body class is NOT present when there are no failures + logging.info("Testing body class when no failed notifications...") + res = client.get(url_for("watchlist.index")) + assert res.status_code == 200 + # The body tag should still exist but without failed-notifications class + # Check by looking for the body tag without the class + response_text = res.data.decode('utf-8') + # Should have body tag but not with failed-notifications class + import re + body_match = re.search(r']*class="([^"]*)"', response_text) + if body_match: + classes = body_match.group(1) + assert 'failed-notifications' not in classes, \ + "Body should NOT have 'failed-notifications' class when dead-letter queue is empty" + logging.info("✓ Body class 'failed-notifications' is NOT present when queue is empty") + + # Test 8: Trigger another failure and test retry_all with empty result + logging.info("Testing retry_all_failed_notifications with empty queue...") + retry_result = retry_all_failed_notifications() + logging.info(f"Retry result with empty queue: {retry_result}") + assert retry_result['total'] == 0, "Should have 0 total when dead-letter is empty" + assert retry_result['success'] == 0, "Should have 0 success when dead-letter is empty" + assert retry_result['failed'] == 0, "Should have 0 failed when dead-letter is empty" + logging.info("✓ retry_all_failed_notifications handles empty queue correctly") + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + logging.info("✓ UI and utility functions test completed successfully") + + +def test_notification_not_failed_while_retrying(client, live_server, measure_memory_usage, datastore_path): + """ + Test that notifications don't show as "Failed" while they're still being retried. + + This verifies the fix for the issue where a notification would appear in both + "Pending/Retrying" and "Failed" counts simultaneously. + """ + from changedetectionio.notification.task_queue import get_failed_notifications + + set_original_response(datastore_path=datastore_path) + + # Set a URL and fetch it + test_url = url_for('test_endpoint', _external=True) + uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) + + wait_for_all_checks(client) + + # Set a broken notification URL that will fail + broken_notification_url = "jsons://broken-url-retry-test-99999/test" + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "notification_urls": broken_notification_url, + "notification_title": "Test Retry Status", + "notification_body": "Testing that retrying tasks don't show as failed", + "notification_format": 'text', + "url": test_url, + "tags": "", + "title": "", + "headers": "", + "time_between_check-minutes": "180", + "fetch_backend": "html_requests", + "time_between_check_use_default": "y" + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + wait_for_all_checks(client) + set_modified_response(datastore_path=datastore_path) + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + # Wait a short time for initial failure (but NOT long enough for all retries) + # With 1 retry and 10s delay, the first attempt fails immediately + # Then it's scheduled for retry in 10 seconds + logging.info("Waiting for initial failure (but not all retries)...") + time.sleep(3) # Wait 3 seconds - enough for first failure, not enough for retry + + # Check dead-letter queue - should be EMPTY because task is still scheduled for retry + failed_notifications = get_failed_notifications() + logging.info(f"Dead-letter queue after 3s: {len(failed_notifications)} items (should be 0 - still retrying)") + + assert len(failed_notifications) == 0, \ + "Dead-letter queue should be EMPTY while task is still being retried " \ + "(task failed but has pending retry, so shouldn't appear as 'Failed' yet)" + + # Check the settings/failed-notifications page while notification is retrying + # The page should show the pending/retrying
    element + logging.info("Checking failed-notifications page while notification is retrying...") + + # First verify the count function returns 1 + from changedetectionio.notification.task_queue import get_pending_notifications_count + count = get_pending_notifications_count() + logging.info(f"Pending count before page load: {count}") + + res = client.get(url_for("settings.failed_notifications")) + assert res.status_code == 200 + + # Check if the page shows pending count + # Look for patterns like "1 notification" or just the number "1" near "Pending" + page_text = res.data.decode('utf-8') + logging.info(f"Page contains 'Pending/Retrying': {'Pending/Retrying' in page_text}") + logging.info(f"Page contains '1': {'1' in page_text}") + + # The count should be displayed in the summary section + assert b"1" in res.data and b"Pending" in res.data, \ + "Page should show pending notification count" + # Should show the pending notification in the dashboard + assert b"QUEUED" in res.data or b"RETRYING" in res.data, \ + "Page should show queued or retrying status badge" + logging.info("✓ Page correctly shows pending/retrying notifications in dashboard") + + # Now wait for all retries to complete + # Retry is scheduled at 10s, so wait another 9 seconds for it to execute and fail + logging.info("Waiting for all retries to complete...") + time.sleep(9) # Total 12 seconds - enough for retry to execute and fail + + # Now check dead-letter queue - should have 1 item (all retries exhausted) + failed_notifications = get_failed_notifications() + logging.info(f"Dead-letter queue after all retries: {len(failed_notifications)} items (should be 1)") + + assert len(failed_notifications) == 1, \ + "Dead-letter queue should have 1 item after ALL retries are exhausted" + + # Verify it's our notification + assert any('broken-url-retry-test-99999' in str(notif.get('notification_data', {})) + for notif in failed_notifications), \ + "Failed notification should be our test notification" + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + logging.info("✓ Notification correctly shows as 'Failed' only after ALL retries exhausted") + + +def test_notification_ajax_log_endpoint(client, live_server, measure_memory_usage, datastore_path): + """ + Test the AJAX endpoint for fetching notification logs. + + This test verifies: + 1. The endpoint returns JSON with expected fields when task exists + 2. The endpoint returns 404 when task doesn't exist + 3. The log data includes apprise_log, task_id, watch_url, notification_urls, error + """ + from changedetectionio.notification.task_queue import get_failed_notifications + import json + + set_original_response(datastore_path=datastore_path) + + # Set a URL and fetch it + test_url = url_for('test_endpoint', _external=True) + uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) + + wait_for_all_checks(client) + + # Set a broken notification URL that will fail + broken_notification_url = "jsons://broken-url-ajax-test-77777/test" + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "notification_urls": broken_notification_url, + "notification_title": "Test AJAX Endpoint", + "notification_body": "Testing AJAX log fetch endpoint", + "notification_format": 'text', + "url": test_url, + "tags": "", + "title": "", + "headers": "", + "time_between_check-minutes": "180", + "fetch_backend": "html_requests", + "time_between_check_use_default": "y" + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + wait_for_all_checks(client) + set_modified_response(datastore_path=datastore_path) + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + # Wait for notification to fail and exhaust all retries + logging.info("Waiting for notification to fail...") + max_wait_time = 20 + start_time = time.time() + failed_found = False + + while time.time() - start_time < max_wait_time: + failed_notifications = get_failed_notifications() + if failed_notifications and len(failed_notifications) > 0: + failed_found = True + logging.info(f"Found {len(failed_notifications)} failed notification(s)") + time.sleep(2) # Wait for result to be fully written + break + time.sleep(1) + + assert failed_found, "Notification should have failed" + + # Get the failed notification to extract task_id + failed_notifications = get_failed_notifications() + assert len(failed_notifications) > 0, "Should have at least one failed notification" + + task_id = failed_notifications[0].get('task_id') + assert task_id, "Failed notification should have a task_id" + logging.info(f"Testing AJAX endpoint with task_id: {task_id}") + + # Test 1: Fetch log for existing task + res = client.get(url_for("notification_dashboard.get_notification_log", task_id=task_id)) + assert res.status_code == 200, "Endpoint should return 200 for existing task" + assert res.content_type == 'application/json', "Response should be JSON" + + # Parse JSON response + log_data = json.loads(res.data) + logging.info(f"Log data keys: {log_data.keys()}") + + # Verify expected fields exist + assert 'task_id' in log_data, "Response should include task_id" + assert 'apprise_log' in log_data, "Response should include apprise_log" + assert log_data['task_id'] == task_id, "Response task_id should match requested task_id" + + # Verify optional fields (may or may not be present depending on notification data) + # These should be present if the notification_data was stored + logging.info(f"Log data: {log_data}") + + # The error field should be present for failed notifications + # Note: The error might be None if the task result hasn't been fully written yet, + # but the AJAX endpoint should still return the field + assert 'error' in log_data, "Response should include error field" + + if log_data['error']: + logging.info(f"✓ AJAX endpoint returned valid JSON with error: {log_data['error'][:50]}...") + else: + logging.info("✓ AJAX endpoint returned valid JSON (error field present but empty - timing dependent)") + + # Test 2: Fetch log for non-existent task + fake_task_id = "nonexistent-task-id-12345" + res = client.get(url_for("notification_dashboard.get_notification_log", task_id=fake_task_id)) + assert res.status_code == 404, "Endpoint should return 404 for non-existent task" + + # Parse 404 JSON response + error_data = json.loads(res.data) + assert 'error' in error_data, "404 response should include error message" + assert 'not found' in error_data['error'].lower(), "Error message should mention 'not found'" + + logging.info("✓ AJAX endpoint correctly returns 404 for non-existent task") + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + logging.info("✓ AJAX log endpoint test completed successfully") + + +def test_notification_ajax_log_shows_apprise_details(client, live_server, measure_memory_usage, datastore_path): + """ + Test that clicking on a retrying/failed notification shows Apprise logs with error details. + + This verifies that the AJAX endpoint returns detailed Apprise logs including + connection errors like "Name or service not known" or similar DNS/connection failures. + """ + from changedetectionio.notification.task_queue import get_pending_notifications, get_failed_notifications + import json + import time + + set_original_response(datastore_path=datastore_path) + + # Set a URL and fetch it + test_url = url_for('test_endpoint', _external=True) + uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) + + wait_for_all_checks(client) + + # Set a broken notification URL that will fail with DNS/connection error + broken_notification_url = "jsons://broken-dns-will-not-resolve-12345xyz/test" + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "notification_urls": broken_notification_url, + "notification_title": "Test Apprise Logs Display", + "notification_body": "Testing that Apprise logs show connection errors", + "notification_format": 'text', + "url": test_url, + "tags": "", + "title": "", + "headers": "", + "time_between_check-minutes": "180", + "fetch_backend": "html_requests", + "time_between_check_use_default": "y" + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + wait_for_all_checks(client) + set_modified_response(datastore_path=datastore_path) + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + assert b'Queued 1 watch for rechecking.' in res.data + + # Wait for notification to fail and be scheduled for retry (first attempt fails) + logging.info("Waiting for initial failure and retry scheduling...") + time.sleep(3) + + # Get pending notifications (should include the retrying task) + pending = get_pending_notifications(limit=50) + logging.info(f"Pending notifications: {len(pending)}") + + if pending and len(pending) > 0: + # Found a pending/retrying notification - test its log + task_id = pending[0].get('task_id') + logging.info(f"Testing log for pending/retrying task: {task_id}") + + res = client.get(url_for("notification_dashboard.get_notification_log", task_id=task_id)) + assert res.status_code == 200, "Should get log for pending/retrying notification" + + log_data = json.loads(res.data) + logging.info(f"Log data for retrying notification: {log_data.keys()}") + logging.info(f"Apprise log excerpt: {log_data.get('apprise_log', '')[:200]}") + logging.info(f"Error excerpt: {log_data.get('error', '')[:200] if log_data.get('error') else 'None'}") + + # Check if error info contains connection failure details + has_error_details = False + if log_data.get('error'): + error_text = str(log_data['error']) + has_error_details = ('Name or service not known' in error_text or + 'Failed to establish' in error_text or + 'Connection' in error_text) + + # Check if apprise_log contains useful error details + has_log_details = False + if log_data.get('apprise_log') and log_data['apprise_log'] != 'No log available': + log_text = log_data['apprise_log'] + has_log_details = ('Name or service not known' in log_text or + 'Failed to establish' in log_text or + 'Connection' in log_text or + 'Socket Exception' in log_text) + + # At least one should have detailed error information + assert has_error_details or has_log_details, \ + f"Apprise logs or error should contain connection failure details. Got apprise_log: {log_data.get('apprise_log', '')[:300]}, error: {log_data.get('error', '')[:300] if log_data.get('error') else 'None'}" + + logging.info("✓ Retrying notification shows Apprise error details") + + # Wait for all retries to complete + logging.info("Waiting for all retries to complete...") + time.sleep(15) + + # Check failed notifications + failed = get_failed_notifications() + if failed and len(failed) > 0: + task_id = failed[0].get('task_id') + logging.info(f"Testing log for failed (dead-letter) task: {task_id}") + + res = client.get(url_for("notification_dashboard.get_notification_log", task_id=task_id)) + assert res.status_code == 200, "Should get log for failed notification" + + log_data = json.loads(res.data) + assert 'error' in log_data, "Failed notification should have error field" + + # Failed notifications should definitely have error details + if log_data.get('error'): + error_text = str(log_data['error']) + assert 'Name or service not known' in error_text or 'Connection' in error_text, \ + f"Failed notification error should contain connection failure details. Got: {error_text[:300]}" + + logging.info("✓ Failed notification shows error details") + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + logging.info("✓ Apprise logs display test completed successfully") + + +def test_send_now_button(client, live_server, measure_memory_usage, datastore_path): + """Test the 'Send Now' button on retrying notifications.""" + import time + import json + import logging + from flask import url_for + from changedetectionio.notification.task_queue import get_pending_notifications + from .util import set_original_response, set_modified_response, wait_for_all_checks + + set_original_response(datastore_path=datastore_path) + + # Add watch with notification + test_url = url_for('test_endpoint', _external=True) + res = client.post( + url_for("ui.form_quick_watch_add"), + data={"url": test_url, "tags": '', 'edit_and_watch_submit_button': 'Edit > Watch'}, + follow_redirects=True + ) + assert b"Watch added in Paused state, saving will unpause" in res.data + + # Enable notification with bad SMTP server to force retry + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "url": test_url, + "tags": "", + "notification_urls": f'mailto://invalid-smtp-server-{int(time.time())}:587/?from=test@example.com&to=recipient@example.com&user=test&pass=test', + "notification_title": "Change detected", + "notification_body": "Triggered text was: {{triggered_text}}", + "notification_format": "Text", + "fetch_backend": "html_requests" + }, + follow_redirects=True + ) + assert b"Updated watch." in res.data + + # Trigger initial check to queue notification + client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + time.sleep(2) + + # Change the endpoint and trigger again to generate a notification + set_modified_response(datastore_path=datastore_path) + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + time.sleep(3) # Wait for notification to fail and be scheduled for retry + + # Check that notification is in "retrying" state + pending = get_pending_notifications(limit=100) + retrying = [n for n in pending if n.get('status') == 'retrying'] + assert len(retrying) > 0, "Should have at least one retrying notification" + + task_id = retrying[0].get('task_id') + assert task_id, "Retrying notification should have task_id" + + logging.info(f"Found retrying notification with task_id: {task_id}") + + # Click "Send Now" button (GET request) + res = client.get(url_for("notification_dashboard.send_now", task_id=task_id), follow_redirects=True) + + # Should redirect back to notification dashboard with message + # The notification will still fail (bad SMTP server), but should be executed immediately + # and removed from the retry schedule + time.sleep(2) + + # Check that notification was removed from retry schedule + pending_after = get_pending_notifications(limit=100) + retrying_after = [n for n in pending_after if n.get('status') == 'retrying' and n.get('task_id') == task_id] + + # The task should be gone from schedule (either succeeded or moved to dead letter) + assert len(retrying_after) == 0, "Notification should be removed from retry schedule after 'Send Now'" + + logging.info("✓ Send Now button successfully executed notification immediately") + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + logging.info("✓ Send Now button test completed successfully") + + +def test_retry_count_display(client, live_server, measure_memory_usage, datastore_path): + """Test that retrying notifications show retry count (X/Y) correctly.""" + import time + import logging + from flask import url_for + from changedetectionio.notification.task_queue import get_pending_notifications + from .util import set_original_response, set_modified_response, wait_for_all_checks + + # For this test, we need enough retries to see progression + # Set to 3 retries so we can verify it shows "2/3" after 2 failures + import os + os.environ['NOTIFICATION_RETRY_COUNT'] = '3' + os.environ['NOTIFICATION_RETRY_DELAY'] = '2' # Fast retries for testing + + # Need to reinit Huey with new config + from changedetectionio.notification.task_queue import init_huey + init_huey(datastore_path) + + set_original_response(datastore_path=datastore_path) + + # Add watch with notification + test_url = url_for('test_endpoint', _external=True) + uuid = client.application.config.get('DATASTORE').add_watch(url=test_url) + wait_for_all_checks(client) + + # Enable notification with bad SMTP server to force retry + broken_notification_url = f'mailto://invalid-smtp-test-{int(time.time())}:587/?from=test@example.com&to=recipient@example.com&user=test&pass=test' + + res = client.post( + url_for("ui.ui_edit.edit_page", uuid="first"), + data={ + "url": test_url, + "tags": "", + "notification_urls": broken_notification_url, + "notification_title": "Retry Count Test", + "notification_body": "Testing retry count display", + "notification_format": "Text", + "fetch_backend": "html_requests", + "headers": "", + "title": "", + "time_between_check-minutes": "180", + "time_between_check_use_default": "y" + }, + follow_redirects=True + ) + # Note: Form may show edit page again, but settings should be saved + wait_for_all_checks(client) + + # Change content to trigger notification + set_modified_response(datastore_path=datastore_path) + res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) + + logging.info("Waiting for notification to fail and retry at least twice...") + + # Wait for notification to fail at least twice + # With 2s retry delay: initial fail + 2s wait + 1st retry fail + 4s wait + 2nd retry = ~8s + max_wait = 15 + start_time = time.time() + found_retry_2_of_3 = False + + while time.time() - start_time < max_wait: + pending = get_pending_notifications(limit=100) + retrying = [n for n in pending if n.get('status') == 'retrying'] + + if retrying: + for notification in retrying: + retry_num = notification.get('retry_number') + total = notification.get('total_retries') + elapsed = time.time() - start_time + + logging.info(f"[{elapsed:.1f}s] Found retrying notification: {retry_num}/{total}") + + # We want to see at least attempt 2/3 (meaning it failed twice and is scheduled for 3rd attempt) + if retry_num and retry_num >= 2 and total == 3: + found_retry_2_of_3 = True + logging.info(f"✓ Found retry count display: {retry_num}/{total}") + break + + if found_retry_2_of_3: + break + + time.sleep(1) + + assert found_retry_2_of_3, \ + f"Should show retry count of at least 2/3 after multiple failures. " \ + f"Last pending: {[(n.get('retry_number'), n.get('total_retries')) for n in pending if n.get('status') == 'retrying']}" + + logging.info("✓ Retry count display verified: Shows X/Y format correctly") + + client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) + + logging.info("✓ Retry count display test completed successfully")