diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3966174a..8b3f4f3c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Add system notifications and update checks
+ -Real-time notifications for system events and alerts
+ -Per-user notification management and dismissal
+ -Update check on startup and every 24 hours to notify users of available versions
+ -Notification center UI component
+ -Automatic cleanup of expired notifications
- Network Access "Reset to Defaults" button: Added a "Reset to Defaults" button to the Network Access settings form, matching the functionality in Proxy Settings. Users can now quickly restore recommended network access settings with one click.
- Streams table column visibility toggle: Added column menu to Streams table header allowing users to show/hide optional columns (TVG-ID, Stats) based on preference, with optional columns hidden by default for cleaner default view.
- Streams table TVG-ID column with search filter and sort: Added TVG-ID column to streams table with search filtering and sort capability for better stream organization. (Closes #866) - Thanks [@CodeBormen](https://github.com/CodeBormen)
diff --git a/core/api_urls.py b/core/api_urls.py
index 75257db1..1ac58455 100644
--- a/core/api_urls.py
+++ b/core/api_urls.py
@@ -6,6 +6,7 @@ from .api_views import (
UserAgentViewSet,
StreamProfileViewSet,
CoreSettingsViewSet,
+ SystemNotificationViewSet,
environment,
version,
rehash_streams_endpoint,
@@ -17,6 +18,7 @@ router = DefaultRouter()
router.register(r'useragents', UserAgentViewSet, basename='useragent')
router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile')
router.register(r'settings', CoreSettingsViewSet, basename='coresettings')
+router.register(r'notifications', SystemNotificationViewSet, basename='systemnotification')
urlpatterns = [
path('settings/env/', environment, name='token_refresh'),
path('version/', version, name='version'),
diff --git a/core/api_views.py b/core/api_views.py
index 44001f95..20ef6249 100644
--- a/core/api_views.py
+++ b/core/api_views.py
@@ -3,6 +3,7 @@
import json
import ipaddress
import logging
+from django.db import models
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -466,3 +467,159 @@ def get_system_events(request):
return Response({
'error': 'Failed to fetch system events'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+
+# ─────────────────────────────
+# System Notifications API
+# ─────────────────────────────
+from .models import SystemNotification, NotificationDismissal
+from .serializers import SystemNotificationSerializer, NotificationDismissalSerializer
+from django.utils import timezone as dj_timezone
+
+
+class SystemNotificationViewSet(viewsets.ModelViewSet):
+ """
+ API endpoint for system notifications.
+ Users can view active notifications and dismiss them.
+ Admins can create and manage notifications.
+ """
+ serializer_class = SystemNotificationSerializer
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ """
+ Return notifications based on user permissions.
+ Filter out expired and dismissed notifications for regular users.
+ Evaluate conditions for developer notifications.
+ """
+ from core.developer_notifications import evaluate_conditions
+ from django.core.cache import cache
+
+ user = self.request.user
+ now = dj_timezone.now()
+
+ queryset = SystemNotification.objects.filter(is_active=True)
+
+ # Filter out expired notifications
+ queryset = queryset.filter(
+ models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)
+ )
+
+ # Filter admin-only notifications for non-admins
+ if not getattr(user, 'is_superuser', False) and getattr(user, 'user_level', 0) < 10:
+ queryset = queryset.filter(admin_only=False)
+
+ # For developer notifications, evaluate conditions
+ # Cache the evaluation per notification to avoid repeated condition checks
+ notifications_to_exclude = []
+ developer_notifications = queryset.filter(source=SystemNotification.Source.DEVELOPER)
+
+ for notification in developer_notifications:
+ action_data = notification.action_data or {}
+ conditions = action_data.get('condition', [])
+
+ if not conditions:
+ continue
+
+ # Cache key based on notification ID and current settings
+ # Cache for 5 minutes to balance freshness with performance
+ cache_key = f'dev_notif_condition_{notification.id}_{user.id}'
+ should_show = cache.get(cache_key)
+
+ if should_show is None:
+ should_show = evaluate_conditions(conditions, user)
+ cache.set(cache_key, should_show, timeout=300) # 5 minutes
+
+ if not should_show:
+ notifications_to_exclude.append(notification.id)
+
+ if notifications_to_exclude:
+ queryset = queryset.exclude(id__in=notifications_to_exclude)
+
+ return queryset
+
+ def get_serializer_context(self):
+ context = super().get_serializer_context()
+ context['request'] = self.request
+ return context
+
+ def list(self, request):
+ """
+ List all active notifications for the current user.
+ Optionally filter by dismissed status.
+ """
+ queryset = self.get_queryset()
+
+ # Optional: filter out already dismissed notifications
+ include_dismissed = request.query_params.get('include_dismissed', 'false').lower() == 'true'
+ if not include_dismissed:
+ dismissed_ids = NotificationDismissal.objects.filter(
+ user=request.user
+ ).values_list('notification_id', flat=True)
+ queryset = queryset.exclude(id__in=dismissed_ids)
+
+ serializer = self.get_serializer(queryset, many=True)
+ return Response({
+ 'notifications': serializer.data,
+ 'count': len(serializer.data),
+ 'unread_count': queryset.count()
+ })
+
+ @action(detail=True, methods=['post'], url_path='dismiss')
+ def dismiss(self, request, pk=None):
+ """Dismiss a notification for the current user."""
+ notification = self.get_object()
+ action_taken = request.data.get('action_taken', None)
+
+ dismissal, created = NotificationDismissal.objects.get_or_create(
+ user=request.user,
+ notification=notification,
+ defaults={'action_taken': action_taken}
+ )
+
+ if not created and action_taken:
+ dismissal.action_taken = action_taken
+ dismissal.save()
+
+ return Response({
+ 'success': True,
+ 'message': 'Notification dismissed',
+ 'notification_key': notification.notification_key
+ })
+
+ @action(detail=False, methods=['post'], url_path='dismiss-all')
+ def dismiss_all(self, request):
+ """Dismiss all notifications for the current user."""
+ notifications = self.get_queryset()
+
+ # Get notifications not yet dismissed
+ dismissed_ids = NotificationDismissal.objects.filter(
+ user=request.user
+ ).values_list('notification_id', flat=True)
+ to_dismiss = notifications.exclude(id__in=dismissed_ids)
+
+ # Create dismissals for all
+ dismissals = [
+ NotificationDismissal(user=request.user, notification=n)
+ for n in to_dismiss
+ ]
+ NotificationDismissal.objects.bulk_create(dismissals, ignore_conflicts=True)
+
+ return Response({
+ 'success': True,
+ 'dismissed_count': len(dismissals)
+ })
+
+ @action(detail=False, methods=['get'], url_path='count')
+ def unread_count(self, request):
+ """Get count of unread notifications."""
+ queryset = self.get_queryset()
+ dismissed_ids = NotificationDismissal.objects.filter(
+ user=request.user
+ ).values_list('notification_id', flat=True)
+ unread_count = queryset.exclude(id__in=dismissed_ids).count()
+
+ return Response({
+ 'unread_count': unread_count
+ })
+
diff --git a/core/apps.py b/core/apps.py
index 96ccfb3b..12f6d054 100644
--- a/core/apps.py
+++ b/core/apps.py
@@ -22,3 +22,52 @@ class CoreConfig(AppConfig):
def ready(self):
# Import signals to ensure they get registered
import core.signals
+
+ # Sync developer notifications and check for version updates on startup
+ # Only run in the main process (not in management commands or migrations)
+ import sys
+ if 'runserver' in sys.argv or 'uwsgi' in sys.argv[0] if sys.argv else False:
+ self._sync_developer_notifications()
+ self._check_version_update()
+
+ def _sync_developer_notifications(self):
+ """Sync developer notifications from JSON file to database."""
+ from django.db import connection
+ import logging
+
+ logger = logging.getLogger(__name__)
+
+ # Check if tables exist (avoid running during migrations)
+ try:
+ with connection.cursor() as cursor:
+ cursor.execute(
+ "SELECT 1 FROM information_schema.tables WHERE table_name = 'core_systemnotification'"
+ )
+ if not cursor.fetchone():
+ # For SQLite
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='core_systemnotification'"
+ )
+ if not cursor.fetchone():
+ return
+ except Exception:
+ # If we can't check, the table might not exist yet
+ pass
+
+ try:
+ from core.developer_notifications import sync_developer_notifications
+ sync_developer_notifications()
+ except Exception as e:
+ logger.warning(f"Failed to sync developer notifications on startup: {e}")
+
+ def _check_version_update(self):
+ """Check for version updates on startup."""
+ import logging
+
+ logger = logging.getLogger(__name__)
+
+ try:
+ from core.tasks import check_for_version_update
+ check_for_version_update.delay()
+ except Exception as e:
+ logger.warning(f"Failed to check for version updates on startup: {e}")
diff --git a/core/developer_notifications.py b/core/developer_notifications.py
new file mode 100644
index 00000000..cf16e7a2
--- /dev/null
+++ b/core/developer_notifications.py
@@ -0,0 +1,412 @@
+"""
+Developer Notification Sync Service
+
+Handles syncing developer-defined notifications from the JSON file to the database.
+This ensures users receive important notifications from the development team
+about recommended settings, security updates, and other announcements.
+
+JSON Schema (see fixtures/developer_notifications.json):
+ {
+ "id": str, # REQUIRED - Unique identifier (notification_key)
+ "title": str, # REQUIRED - Notification heading
+ "message": str, # REQUIRED - Notification body text
+
+ "notification_type": str, # OPTIONAL - 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info' (default: 'info')
+ "priority": str, # OPTIONAL - 'low', 'normal', 'high', 'critical' (default: 'normal')
+ "min_version": str | null, # OPTIONAL - Minimum version (inclusive), e.g., "0.17.0" (default: null)
+ "max_version": str | null, # OPTIONAL - Maximum version (inclusive), e.g., "0.18.1" (default: null)
+ "created_at": str, # OPTIONAL - ISO timestamp for tracking
+ "expires_at": str | null, # OPTIONAL - ISO timestamp when notification expires (default: null)
+ "condition": list[str], # OPTIONAL - List of condition check names, AND logic (default: [])
+ "user_level": str, # OPTIONAL - 'all' or 'admin' (default: 'all')
+ "action_url": str | null, # OPTIONAL - Internal navigation URL (e.g., "/settings#network-access")
+ "action_text": str | null, # OPTIONAL - Text for action button (required if action_url is set)
+ }
+
+Condition Checks:
+ Conditions are function names from CONDITION_CHECKS registry that evaluate
+ whether a notification should be shown. All conditions must pass (AND logic).
+
+ Available conditions:
+ - 'm3u_epg_network_insecure': M3U/EPG endpoint allows access from anywhere
+
+ To add new conditions:
+ 1. Define a function: check_your_condition(user) -> bool
+ 2. Add to CONDITION_CHECKS registry
+ 3. Reference in JSON: "condition": ["your_condition"]
+
+Sync Behavior:
+ - Runs on startup (see apps.py)
+ - Runs when relevant settings change (see signals.py)
+ - Adds new notifications if in version range and not expired
+ - Updates existing notifications with latest data
+ - Removes notifications that are:
+ * No longer in JSON file
+ * Out of current version range
+ * Past expiration date
+ - Sends websocket event to refresh frontend
+ - Cache invalidated when triggering settings change
+"""
+import json
+import logging
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Callable
+
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+from packaging import version
+
+from version import __version__
+
+logger = logging.getLogger(__name__)
+
+# Path to developer notifications JSON file
+NOTIFICATIONS_FILE = Path(__file__).parent / 'fixtures' / 'developer_notifications.json'
+
+
+# ─────────────────────────────
+# Condition Checks
+# ─────────────────────────────
+# Each condition function receives (user) and returns True if the notification should show
+
+def check_network_access_is_default(user, endpoint: str = 'M3U_EPG') -> bool:
+ """
+ Check if network access settings for a specific endpoint are insecure (allow all).
+
+ Args:
+ user: The user object (unused but required for condition check signature)
+ endpoint: The endpoint to check (e.g., 'M3U_EPG', 'XC_API')
+
+ Returns:
+ True if the notification should show (insecure settings detected)
+ """
+ from core.models import CoreSettings, NETWORK_ACCESS_KEY
+
+ try:
+ network_settings = CoreSettings._get_group(NETWORK_ACCESS_KEY, {})
+
+ # Empty settings are secure (defaults to local network only)
+ if not network_settings:
+ return False
+
+ # Get the specific endpoint's allowed networks (stored as comma-separated string)
+ allowed_networks_str = network_settings.get(endpoint, '')
+ if not allowed_networks_str:
+ return False
+
+ # Parse comma-separated network addresses
+ allowed_networks = [net.strip() for net in allowed_networks_str.split(',')]
+
+ # Check if settings allow access from anywhere (insecure)
+ if '0.0.0.0/0' in allowed_networks or '::/0' in allowed_networks:
+ return True
+
+ return False
+ except Exception as e:
+ logger.warning(f"Error checking network_access_is_default condition for {endpoint}: {e}")
+ return False
+
+
+# Registry of all available condition checks
+CONDITION_CHECKS: dict[str, Callable] = {
+ 'm3u_epg_network_insecure': lambda user: check_network_access_is_default(user, 'M3U_EPG'),
+ # Add more conditions here as needed
+ # 'transcode_not_configured': check_transcode_not_configured,
+ # 'no_backup_configured': check_no_backup_configured,
+}
+
+
+# ─────────────────────────────
+# Version Utilities
+# ─────────────────────────────
+
+def parse_version(version_str: str | None) -> version.Version | None:
+ """Parse a version string, returning None if invalid or empty."""
+ if not version_str:
+ return None
+ try:
+ return version.parse(version_str)
+ except Exception:
+ return None
+
+
+def is_version_in_range(
+ current_version: str,
+ min_version: str | None,
+ max_version: str | None
+) -> bool:
+ """Check if current version is within the specified range."""
+ current = parse_version(current_version)
+ if not current:
+ return True # If we can't parse version, show notification
+
+ min_ver = parse_version(min_version)
+ max_ver = parse_version(max_version)
+
+ if min_ver and current < min_ver:
+ return False
+ if max_ver and current > max_ver:
+ return False
+
+ return True
+
+
+# ─────────────────────────────
+# Notification Evaluation
+# ─────────────────────────────
+
+def evaluate_conditions(conditions: list[str] | str | None, user) -> bool:
+ """
+ Evaluate notification conditions for a user.
+ All conditions must pass (AND logic).
+ """
+ if not conditions:
+ return True
+
+ # Normalize to list
+ if isinstance(conditions, str):
+ conditions = [conditions]
+
+ for condition in conditions:
+ if condition not in CONDITION_CHECKS:
+ logger.warning(f"Unknown condition: {condition}")
+ continue
+
+ try:
+ if not CONDITION_CHECKS[condition](user):
+ return False
+ except Exception as e:
+ logger.error(f"Error evaluating condition {condition}: {e}")
+ # On error, skip this condition (fail open)
+ continue
+
+ return True
+
+
+def should_show_notification(notification_data: dict, user) -> bool:
+ """
+ Determine if a notification should be shown to a specific user.
+ Checks version range, user level, and conditions.
+ """
+ # Check version range
+ if not is_version_in_range(
+ __version__,
+ notification_data.get('min_version'),
+ notification_data.get('max_version')
+ ):
+ return False
+
+ # Check user level
+ user_level = notification_data.get('user_level', 'all')
+ if user_level == 'admin' and not getattr(user, 'is_superuser', False):
+ return False
+
+ # Check conditions
+ conditions = notification_data.get('condition', [])
+ if not evaluate_conditions(conditions, user):
+ return False
+
+ return True
+
+
+# ─────────────────────────────
+# Sync Service
+# ─────────────────────────────
+
+def load_developer_notifications() -> list[dict]:
+ """Load notifications from the JSON file."""
+ if not NOTIFICATIONS_FILE.exists():
+ logger.warning(f"Developer notifications file not found: {NOTIFICATIONS_FILE}")
+ return []
+
+ try:
+ with open(NOTIFICATIONS_FILE, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+ return data.get('notifications', [])
+ except json.JSONDecodeError as e:
+ logger.error(f"Error parsing developer notifications JSON: {e}")
+ return []
+ except Exception as e:
+ logger.error(f"Error loading developer notifications: {e}")
+ return []
+
+
+def sync_developer_notifications() -> dict[str, int]:
+ """
+ Sync developer notifications from JSON file to database.
+
+ - Adds new notifications that don't exist in the DB
+ - Removes DB notifications that are no longer in the JSON file
+ - Updates existing notifications if they've changed
+
+ Returns a dict with counts of added, updated, and removed notifications.
+ """
+ from core.models import SystemNotification
+
+ results = {'added': 0, 'updated': 0, 'removed': 0, 'skipped': 0}
+
+ notifications = load_developer_notifications()
+ json_notification_keys = set()
+ notifications_to_remove = set() # Track notifications to remove (out of range or expired)
+
+ for notif_data in notifications:
+ notification_id = notif_data.get('id')
+ if not notification_id:
+ logger.warning("Notification missing 'id' field, skipping")
+ results['skipped'] += 1
+ continue
+
+ json_notification_keys.add(notification_id)
+
+ # Check version constraints (only add if current version is in range)
+ if not is_version_in_range(
+ __version__,
+ notif_data.get('min_version'),
+ notif_data.get('max_version')
+ ):
+ logger.debug(f"Notification {notification_id} not in version range, marking for removal")
+ results['skipped'] += 1
+ notifications_to_remove.add(notification_id)
+ continue
+
+ # Parse expires_at if provided
+ expires_at = None
+ if notif_data.get('expires_at'):
+ try:
+ expires_at = datetime.fromisoformat(
+ notif_data['expires_at'].replace('Z', '+00:00')
+ )
+ # Skip if already expired and mark for removal
+ if expires_at < timezone.now():
+ logger.debug(f"Notification {notification_id} has expired, marking for removal")
+ results['skipped'] += 1
+ notifications_to_remove.add(notification_id)
+ continue
+ except (ValueError, TypeError) as e:
+ logger.warning(f"Invalid expires_at for {notification_id}: {e}")
+
+ # Map notification_type from JSON to model choices
+ type_mapping = {
+ 'version_update': SystemNotification.NotificationType.VERSION_UPDATE,
+ 'setting_recommendation': SystemNotification.NotificationType.SETTING_RECOMMENDATION,
+ 'announcement': SystemNotification.NotificationType.ANNOUNCEMENT,
+ 'warning': SystemNotification.NotificationType.WARNING,
+ 'info': SystemNotification.NotificationType.INFO,
+ }
+ notification_type = type_mapping.get(
+ notif_data.get('notification_type', 'info'),
+ SystemNotification.NotificationType.INFO
+ )
+
+ # Map priority
+ priority_mapping = {
+ 'low': SystemNotification.Priority.LOW,
+ 'normal': SystemNotification.Priority.NORMAL,
+ 'high': SystemNotification.Priority.HIGH,
+ 'critical': SystemNotification.Priority.CRITICAL,
+ }
+ priority = priority_mapping.get(
+ notif_data.get('priority', 'normal'),
+ SystemNotification.Priority.NORMAL
+ )
+
+ # Prepare action_data
+ action_data = {
+ 'action_url': notif_data.get('action_url'),
+ 'action_text': notif_data.get('action_text'),
+ 'condition': notif_data.get('condition', []),
+ 'min_version': notif_data.get('min_version'),
+ 'max_version': notif_data.get('max_version'),
+ 'user_level': notif_data.get('user_level', 'all'),
+ }
+
+ # Determine if admin-only based on user_level
+ admin_only = notif_data.get('user_level', 'all') == 'admin'
+
+ # Create or update the notification
+ notification, created = SystemNotification.objects.update_or_create(
+ notification_key=notification_id,
+ defaults={
+ 'notification_type': notification_type,
+ 'priority': priority,
+ 'source': SystemNotification.Source.DEVELOPER,
+ 'title': notif_data.get('title', 'Notification'),
+ 'message': notif_data.get('message', ''),
+ 'action_data': action_data,
+ 'is_active': True,
+ 'admin_only': admin_only,
+ 'expires_at': expires_at,
+ }
+ )
+
+ if created:
+ logger.info(f"Added developer notification: {notification_id}")
+ results['added'] += 1
+ else:
+ logger.debug(f"Updated developer notification: {notification_id}")
+ results['updated'] += 1
+
+ # Remove developer notifications that are:
+ # - No longer in the JSON file, OR
+ # - Out of version range for the current version, OR
+ # - Expired
+ removed_count, _ = SystemNotification.objects.filter(
+ source=SystemNotification.Source.DEVELOPER
+ ).filter(
+ models.Q(notification_key__in=notifications_to_remove) |
+ ~models.Q(notification_key__in=json_notification_keys)
+ ).delete()
+
+ if removed_count:
+ logger.info(f"Removed {removed_count} obsolete/expired/out-of-range developer notification(s)")
+ results['removed'] = removed_count
+
+ logger.info(
+ f"Developer notification sync complete: "
+ f"{results['added']} added, {results['updated']} updated, "
+ f"{results['removed']} removed, {results['skipped']} skipped"
+ )
+
+ # Send websocket notification to frontend to refresh notifications
+ try:
+ from core.utils import send_websocket_update
+ send_websocket_update('updates', 'update', {
+ 'type': 'notifications_cleared',
+ })
+ logger.debug("Sent websocket notification for notifications refresh")
+ except Exception as e:
+ logger.warning(f"Failed to send websocket update: {e}")
+
+ return results
+
+
+def get_user_developer_notifications(user) -> list:
+ """
+ Get all developer notifications that should be shown to a specific user.
+ Evaluates conditions and user_level for each notification.
+ """
+ from core.models import SystemNotification
+
+ # Get all active developer notifications
+ notifications = SystemNotification.objects.filter(
+ source=SystemNotification.Source.DEVELOPER,
+ is_active=True
+ )
+
+ # Filter by admin_only based on user
+ if not getattr(user, 'is_superuser', False):
+ notifications = notifications.filter(admin_only=False)
+
+ # Filter by conditions
+ result = []
+ for notification in notifications:
+ action_data = notification.action_data or {}
+
+ # Evaluate conditions
+ conditions = action_data.get('condition', [])
+ if evaluate_conditions(conditions, user):
+ result.append(notification)
+
+ return result
diff --git a/core/fixtures/developer_notifications.json b/core/fixtures/developer_notifications.json
new file mode 100644
index 00000000..0040682e
--- /dev/null
+++ b/core/fixtures/developer_notifications.json
@@ -0,0 +1,43 @@
+{
+ "_schema_documentation": {
+ "description": "Developer notification definitions. Each notification is evaluated at sync time.",
+ "fields": {
+ "id": "[REQUIRED] Unique identifier (notification_key in database).",
+ "notification_type": "[OPTIONAL] Type: 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info'. Default: 'info'",
+ "priority": "[OPTIONAL] Priority level: 'low', 'normal', 'high', 'critical'. Default: 'normal'",
+ "title": "[REQUIRED] Notification title/heading.",
+ "message": "[REQUIRED] Detailed notification message body.",
+ "min_version": "[OPTIONAL] Minimum version (inclusive). null = no minimum. Example: '0.17.0'",
+ "max_version": "[OPTIONAL] Maximum version (inclusive). null = no maximum. Example: '0.18.1'",
+ "created_at": "[OPTIONAL] ISO timestamp when notification was created. For tracking only.",
+ "expires_at": "[OPTIONAL] ISO timestamp when notification expires. null = never expires. Example: '2026-12-31T23:59:59Z'",
+ "condition": "[OPTIONAL] Array of condition check names that must all pass. Empty/null = always show. Example: ['m3u_epg_network_insecure']",
+ "user_level": "[OPTIONAL] User level required: 'all' or 'admin'. Default: 'all'",
+ "action_url": "[OPTIONAL] Internal URL to navigate to when action button clicked. Example: '/settings#network-access'",
+ "action_text": "[OPTIONAL] Text for action button. Required if action_url is set. Example: 'Review Settings'"
+ },
+ "notes": [
+ "Notifications are synced from this file to database on startup and when relevant settings change",
+ "Out-of-range versions are automatically removed from database",
+ "Expired notifications are automatically removed from database",
+ "Conditions are evaluated per-user at display time (see CONDITION_CHECKS in developer_notifications.py)"
+ ]
+ },
+ "notifications": [
+ {
+ "id": "network_security_m3u_epg",
+ "notification_type": "warning",
+ "priority": "high",
+ "title": "Network Access Security Warning",
+ "message": "Your EPG/M3U output is accessible from any network. Consider restricting access to improve security.",
+ "min_version": null,
+ "max_version": null,
+ "created_at": "2026-02-02T00:00:00Z",
+ "expires_at": null,
+ "condition": ["m3u_epg_network_insecure"],
+ "user_level": "admin",
+ "action_url": "/settings#network-access",
+ "action_text": "Review Settings"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/core/migrations/0021_systemnotification_notificationdismissal.py b/core/migrations/0021_systemnotification_notificationdismissal.py
new file mode 100644
index 00000000..66eb9b18
--- /dev/null
+++ b/core/migrations/0021_systemnotification_notificationdismissal.py
@@ -0,0 +1,52 @@
+# Generated by Django 5.2.9 on 2026-02-02 20:38
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0020_change_coresettings_value_to_jsonfield'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='SystemNotification',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('notification_key', models.CharField(db_index=True, max_length=255, unique=True)),
+ ('notification_type', models.CharField(choices=[('version_update', 'Version Update Available'), ('setting_recommendation', 'Recommended Setting Change'), ('announcement', 'System Announcement'), ('warning', 'Warning'), ('info', 'Information')], db_index=True, default='info', max_length=50)),
+ ('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('critical', 'Critical')], default='normal', max_length=20)),
+ ('source', models.CharField(choices=[('system', 'System Generated'), ('developer', 'Developer Notification')], db_index=True, default='system', max_length=20)),
+ ('title', models.CharField(max_length=255)),
+ ('message', models.TextField()),
+ ('action_data', models.JSONField(blank=True, default=dict)),
+ ('is_active', models.BooleanField(db_index=True, default=True)),
+ ('admin_only', models.BooleanField(default=False)),
+ ('expires_at', models.DateTimeField(blank=True, db_index=True, null=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'ordering': ['-priority', '-created_at'],
+ 'indexes': [models.Index(fields=['is_active', '-created_at'], name='core_system_is_acti_afab03_idx'), models.Index(fields=['notification_type', 'is_active'], name='core_system_notific_2179e3_idx'), models.Index(fields=['source', 'is_active'], name='core_system_source_a35829_idx')],
+ },
+ ),
+ migrations.CreateModel(
+ name='NotificationDismissal',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('dismissed_at', models.DateTimeField(auto_now_add=True)),
+ ('action_taken', models.CharField(blank=True, max_length=50, null=True)),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissed_notifications', to=settings.AUTH_USER_MODEL)),
+ ('notification', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissals', to='core.systemnotification')),
+ ],
+ options={
+ 'indexes': [models.Index(fields=['user', 'notification'], name='core_notifi_user_id_93e02e_idx')],
+ 'unique_together': {('user', 'notification')},
+ },
+ ),
+ ]
diff --git a/core/models.py b/core/models.py
index 683acb0d..a694a76b 100644
--- a/core/models.py
+++ b/core/models.py
@@ -370,3 +370,153 @@ class SystemEvent(models.Model):
def __str__(self):
return f"{self.event_type} - {self.channel_name or 'N/A'} @ {self.timestamp}"
+
+
+class SystemNotification(models.Model):
+ """
+ Stores system notifications that users can view and dismiss.
+ Used for version updates, recommended settings, announcements, etc.
+ """
+ class NotificationType(models.TextChoices):
+ VERSION_UPDATE = 'version_update', 'Version Update Available'
+ SETTING_RECOMMENDATION = 'setting_recommendation', 'Recommended Setting Change'
+ ANNOUNCEMENT = 'announcement', 'System Announcement'
+ WARNING = 'warning', 'Warning'
+ INFO = 'info', 'Information'
+
+ class Priority(models.TextChoices):
+ LOW = 'low', 'Low'
+ NORMAL = 'normal', 'Normal'
+ HIGH = 'high', 'High'
+ CRITICAL = 'critical', 'Critical'
+
+ class Source(models.TextChoices):
+ SYSTEM = 'system', 'System Generated'
+ DEVELOPER = 'developer', 'Developer Notification'
+
+ # Unique identifier for the notification (e.g., 'version-0.19.0', 'setting-proxy-buffer')
+ # This allows deduplication and targeted dismissals
+ notification_key = models.CharField(max_length=255, unique=True, db_index=True)
+
+ notification_type = models.CharField(
+ max_length=50,
+ choices=NotificationType.choices,
+ default=NotificationType.INFO,
+ db_index=True
+ )
+ priority = models.CharField(
+ max_length=20,
+ choices=Priority.choices,
+ default=Priority.NORMAL
+ )
+
+ # Source of the notification (system-generated vs developer-defined)
+ source = models.CharField(
+ max_length=20,
+ choices=Source.choices,
+ default=Source.SYSTEM,
+ db_index=True
+ )
+
+ title = models.CharField(max_length=255)
+ message = models.TextField()
+
+ # Optional action data (e.g., setting key/value for recommendations, release URL for versions)
+ action_data = models.JSONField(default=dict, blank=True)
+
+ # Whether this notification is currently active
+ is_active = models.BooleanField(default=True, db_index=True)
+
+ # Admin-only notifications require admin privileges to view
+ admin_only = models.BooleanField(default=False)
+
+ # Auto-expire after this date (null = never expires)
+ expires_at = models.DateTimeField(null=True, blank=True, db_index=True)
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['-priority', '-created_at']
+ indexes = [
+ models.Index(fields=['is_active', '-created_at']),
+ models.Index(fields=['notification_type', 'is_active']),
+ models.Index(fields=['source', 'is_active']),
+ ]
+
+ def __str__(self):
+ return f"[{self.notification_type}] {self.title}"
+
+ @classmethod
+ def create_version_notification(cls, version, release_url=None, release_notes=None):
+ """Create or update a version update notification. Returns (notification, created) tuple."""
+ key = f"version-{version}"
+ notification, created = cls.objects.update_or_create(
+ notification_key=key,
+ defaults={
+ 'notification_type': cls.NotificationType.VERSION_UPDATE,
+ 'priority': cls.Priority.HIGH,
+ 'title': f'Version {version} Available',
+ 'message': f'A new version of Dispatcharr ({version}) is available.',
+ 'action_data': {
+ 'version': version,
+ 'release_url': release_url,
+ 'release_notes': release_notes,
+ },
+ 'is_active': True,
+ 'admin_only': True,
+ }
+ )
+ return notification, created
+
+ @classmethod
+ def create_setting_recommendation(cls, setting_key, recommended_value, reason, current_value=None):
+ """Create a setting recommendation notification. Returns (notification, created) tuple."""
+ key = f"setting-{setting_key}"
+ notification, created = cls.objects.update_or_create(
+ notification_key=key,
+ defaults={
+ 'notification_type': cls.NotificationType.SETTING_RECOMMENDATION,
+ 'priority': cls.Priority.NORMAL,
+ 'title': f'Recommended Setting: {setting_key}',
+ 'message': reason,
+ 'action_data': {
+ 'setting_key': setting_key,
+ 'recommended_value': recommended_value,
+ 'current_value': current_value,
+ },
+ 'is_active': True,
+ 'admin_only': True,
+ }
+ )
+ return notification, created
+
+
+class NotificationDismissal(models.Model):
+ """
+ Tracks which users have dismissed which notifications.
+ Allows users to dismiss notifications once without seeing them again.
+ """
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name='dismissed_notifications'
+ )
+ notification = models.ForeignKey(
+ SystemNotification,
+ on_delete=models.CASCADE,
+ related_name='dismissals'
+ )
+ dismissed_at = models.DateTimeField(auto_now_add=True)
+
+ # Optional: track if user accepted/applied the recommendation
+ action_taken = models.CharField(max_length=50, blank=True, null=True)
+
+ class Meta:
+ unique_together = ['user', 'notification']
+ indexes = [
+ models.Index(fields=['user', 'notification']),
+ ]
+
+ def __str__(self):
+ return f"{self.user.username} dismissed {self.notification.notification_key}"
diff --git a/core/serializers.py b/core/serializers.py
index b2bd8ecc..03ba33e8 100644
--- a/core/serializers.py
+++ b/core/serializers.py
@@ -64,7 +64,12 @@ class CoreSettingsSerializer(serializers.ModelSerializer):
}
)
- return super().update(instance, validated_data)
+ result = super().update(instance, validated_data)
+
+ # Note: Cache invalidation and notification sync is handled by post_save signal
+ # in core/signals.py to ensure it happens even if settings are updated elsewhere
+
+ return result
class ProxySettingsSerializer(serializers.Serializer):
"""Serializer for proxy settings stored as JSON in CoreSettings"""
@@ -98,3 +103,45 @@ class ProxySettingsSerializer(serializers.Serializer):
if value < 0 or value > 60:
raise serializers.ValidationError("Channel init grace period must be between 0 and 60 seconds")
return value
+
+
+class SystemNotificationSerializer(serializers.ModelSerializer):
+ """Serializer for system notifications."""
+ is_dismissed = serializers.SerializerMethodField()
+
+ class Meta:
+ from .models import SystemNotification
+ model = SystemNotification
+ fields = [
+ 'id',
+ 'notification_key',
+ 'notification_type',
+ 'priority',
+ 'title',
+ 'message',
+ 'action_data',
+ 'is_active',
+ 'admin_only',
+ 'expires_at',
+ 'created_at',
+ 'is_dismissed',
+ 'source',
+ ]
+ read_only_fields = ['created_at']
+
+ def get_is_dismissed(self, obj):
+ """Check if the current user has dismissed this notification."""
+ request = self.context.get('request')
+ if request and request.user.is_authenticated:
+ return obj.dismissals.filter(user=request.user).exists()
+ return False
+
+
+class NotificationDismissalSerializer(serializers.ModelSerializer):
+ """Serializer for notification dismissals."""
+
+ class Meta:
+ from .models import NotificationDismissal
+ model = NotificationDismissal
+ fields = ['id', 'notification', 'dismissed_at', 'action_taken']
+ read_only_fields = ['dismissed_at']
diff --git a/core/signals.py b/core/signals.py
index 6844a890..bbb73dd9 100644
--- a/core/signals.py
+++ b/core/signals.py
@@ -1,9 +1,39 @@
-from django.db.models.signals import pre_delete
+from django.db.models.signals import pre_delete, post_save
from django.dispatch import receiver
from django.core.exceptions import ValidationError
-from .models import StreamProfile
+from .models import StreamProfile, CoreSettings, NETWORK_ACCESS_KEY
@receiver(pre_delete, sender=StreamProfile)
def prevent_deletion_if_locked(sender, instance, **kwargs):
if instance.locked:
raise ValidationError("This profile is locked and cannot be deleted.")
+
+@receiver(post_save, sender=CoreSettings)
+def handle_network_access_update(sender, instance, **kwargs):
+ """Invalidate cache and sync notifications when network access settings change."""
+ if instance.key == NETWORK_ACCESS_KEY:
+ from django.core.cache import cache
+ from core.developer_notifications import sync_developer_notifications
+ import logging
+
+ logger = logging.getLogger(__name__)
+
+ # Invalidate all notification condition caches
+ try:
+ cache.delete_pattern('dev_notif_condition_*')
+ logger.info("Invalidated notification condition cache due to network access settings update")
+ except Exception as e:
+ logger.warning(f"Failed to delete cache pattern: {e}")
+ # Fallback: try to clear entire cache (if delete_pattern not supported)
+ try:
+ cache.clear()
+ except Exception:
+ pass
+
+ # Re-sync developer notifications to re-evaluate conditions
+ # (websocket notification is sent by sync_developer_notifications)
+ try:
+ sync_developer_notifications()
+ logger.info("Re-synced developer notifications after network access settings update")
+ except Exception as e:
+ logger.error(f"Failed to sync developer notifications: {e}")
diff --git a/core/tasks.py b/core/tasks.py
index 78a3e0ec..44ddc68a 100644
--- a/core/tasks.py
+++ b/core/tasks.py
@@ -765,3 +765,227 @@ def cleanup_vod_persistent_connections():
except Exception as e:
logger.error(f"Error during VOD persistent connection cleanup: {e}")
+
+
+@shared_task
+def check_for_version_update():
+ """
+ Check for new Dispatcharr versions on GitHub and create a notification if available.
+ This task should be run periodically (e.g., daily) via Celery Beat.
+
+ For dev builds (identified by __timestamp__), checks for stable releases only.
+ For production builds, checks for stable releases.
+
+ Note: Dev builds are container images from the dev branch and don't have GitHub releases.
+ This checks if a stable release is available so dev users know when to upgrade.
+ """
+ import requests
+ from datetime import datetime, timezone
+ from packaging import version as pkg_version
+ from version import __version__, __timestamp__
+ from core.models import SystemNotification
+ from core.utils import send_websocket_notification
+
+ try:
+ is_dev_build = __timestamp__ is not None
+ DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'}
+ if is_dev_build:
+ # Check Docker Hub for newer dev builds
+ docker_hub_url = "https://hub.docker.com/v2/repositories/dispatcharr/dispatcharr/tags/dev"
+
+ response = requests.get(docker_hub_url, headers=DISPATCHARR_HEADERS, timeout=10)
+
+ if response.status_code != 200:
+ logger.warning(f"Failed to check Docker Hub for dev updates: HTTP {response.status_code}")
+ return
+
+ dev_tag_data = response.json()
+ docker_last_updated = dev_tag_data.get("last_updated")
+
+ if not docker_last_updated:
+ logger.warning("No last_updated timestamp found in Docker Hub response")
+ return
+
+ # Parse timestamps for comparison
+ local_dt = datetime.strptime(__timestamp__, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
+ docker_dt = datetime.fromisoformat(docker_last_updated.replace('Z', '+00:00'))
+
+ # Calculate difference in minutes
+ diff_minutes = (docker_dt - local_dt).total_seconds() / 60
+
+ # Threshold to account for build/push time differences
+ THRESHOLD_MINUTES = 10
+
+ if diff_minutes > THRESHOLD_MINUTES:
+ logger.info(f"New dev build available on Docker Hub (updated {int(diff_minutes)} minutes after current build)")
+
+ # Delete any old version update notifications (both dev and stable, in case user switched)
+ deleted_count = SystemNotification.objects.filter(
+ notification_type='version_update'
+ ).delete()[0]
+ if deleted_count > 0:
+ logger.debug(f"Deleted {deleted_count} old dev build notification(s)")
+ send_websocket_update(
+ 'updates',
+ 'update',
+ {
+ 'success': True,
+ 'type': 'notifications_cleared',
+ 'count': deleted_count
+ }
+ )
+
+ # Create notification for new dev build
+ notification, created = SystemNotification.objects.get_or_create(
+ notification_key=f'version-dev-{docker_last_updated}',
+ defaults={
+ 'notification_type': 'version_update',
+ 'title': 'New Dev Build Available',
+ 'message': f'A newer development build is available on Docker Hub (v{__version__}-dev)',
+ 'priority': 'medium',
+ 'action_data': {
+ 'current_version': __version__,
+ 'current_timestamp': __timestamp__,
+ 'docker_updated': docker_last_updated,
+ 'update_url': 'https://hub.docker.com/r/dispatcharr/dispatcharr/tags'
+ },
+ 'is_active': True,
+ 'admin_only': True,
+ }
+ )
+
+ if created:
+ # Only send WebSocket for newly created notifications
+ send_websocket_notification(notification)
+ logger.info(f"New dev build notification created and sent via WebSocket")
+ else:
+ logger.debug(f"Dev build is up to date (Docker Hub image is {abs(int(diff_minutes))} minutes {'newer' if diff_minutes > 0 else 'older'})")
+
+ # Delete all version update notifications when up to date (both dev and stable)
+ deleted_count = SystemNotification.objects.filter(
+ notification_type='version_update'
+ ).delete()[0]
+
+ if deleted_count > 0:
+ logger.info(f"Deleted {deleted_count} outdated dev build notification(s)")
+ send_websocket_update(
+ 'updates',
+ 'update',
+ {
+ 'success': True,
+ 'type': 'notifications_cleared',
+ 'count': deleted_count
+ }
+ )
+ else:
+ # Production build - check GitHub for stable releases
+ github_api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest"
+ headers = {"Accept": "application/vnd.github.v3+json", **DISPATCHARR_HEADERS}
+ response = requests.get(
+ github_api_url,
+ headers=headers,
+ timeout=10
+ )
+
+ if response.status_code != 200:
+ logger.warning(f"Failed to check for updates: HTTP {response.status_code}")
+ return
+
+ release_data = response.json()
+ latest_version = release_data.get("tag_name", "").lstrip("v")
+ release_url = release_data.get("html_url", "")
+
+ if not latest_version:
+ logger.warning("No version tag found in GitHub release")
+ return
+
+ # Compare versions
+ current = pkg_version.parse(__version__)
+ latest = pkg_version.parse(latest_version)
+ if latest > current:
+ logger.info(f"New stable version available: {latest_version} (current: {__version__})")
+
+ # Delete any old version update notifications (superseded by this one)
+ deleted_count = SystemNotification.objects.filter(
+ notification_type='version_update'
+ ).exclude(
+ notification_key=f"version-{latest_version}"
+ ).delete()[0]
+ if deleted_count > 0:
+ logger.debug(f"Deleted {deleted_count} old version notification(s)")
+ send_websocket_update(
+ 'updates',
+ 'update',
+ {
+ 'success': True,
+ 'type': 'notifications_cleared',
+ 'count': deleted_count
+ }
+ )
+
+ # Create or update the notification for the new version
+ notification, created = SystemNotification.create_version_notification(
+ version=latest_version,
+ release_url=release_url,
+ )
+
+ if created:
+ # Only send WebSocket for newly created notifications
+ send_websocket_notification(notification)
+ logger.info(f"New version notification created and sent via WebSocket")
+ else:
+ logger.debug(f"Dispatcharr is up to date (v{__version__})")
+
+ # Delete ALL version update notifications when up to date (no longer needed)
+ deleted_count = SystemNotification.objects.filter(
+ notification_type='version_update'
+ ).delete()[0]
+
+ if deleted_count > 0:
+ logger.info(f"Deleted {deleted_count} outdated version notification(s)")
+ send_websocket_update(
+ 'updates',
+ 'update',
+ {
+ 'success': True,
+ 'type': 'notifications_cleared',
+ 'count': deleted_count
+ }
+ )
+
+ except requests.RequestException as e:
+ logger.warning(f"Network error checking for updates: {e}")
+ except Exception as e:
+ logger.error(f"Error checking for version updates: {e}")
+
+
+def create_setting_recommendation(setting_key, recommended_value, reason, current_value=None):
+ """
+ Create a setting recommendation notification.
+ This is a helper function that can be called from anywhere in the codebase.
+
+ Args:
+ setting_key: The setting key (e.g., 'proxy_settings.buffering_timeout')
+ recommended_value: The recommended value for the setting
+ reason: Why this setting is recommended
+ current_value: The current value (optional)
+
+ Returns:
+ The created SystemNotification instance
+ """
+ from core.models import SystemNotification
+ from core.utils import send_websocket_notification
+
+ notification, created = SystemNotification.create_setting_recommendation(
+ setting_key=setting_key,
+ recommended_value=recommended_value,
+ reason=reason,
+ current_value=current_value
+ )
+
+ # Only send via WebSocket for newly created notifications
+ if created:
+ send_websocket_notification(notification)
+
+ return notification
+
diff --git a/core/utils.py b/core/utils.py
index e3d6c389..5c5a0045 100644
--- a/core/utils.py
+++ b/core/utils.py
@@ -437,3 +437,76 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details):
except Exception as e:
# Don't let event logging break the main application
logger.error(f"Failed to log system event {event_type}: {e}")
+
+
+def send_websocket_notification(notification):
+ """
+ Send a system notification to all connected WebSocket clients.
+
+ Args:
+ notification: A SystemNotification model instance or dict with notification data
+
+ Example:
+ from core.models import SystemNotification
+ notification = SystemNotification.create_version_notification('0.19.0', 'https://...')
+ send_websocket_notification(notification)
+ """
+ try:
+ channel_layer = get_channel_layer()
+
+ # Convert model instance to dict if needed
+ if hasattr(notification, 'id'):
+ notification_data = {
+ 'id': notification.id,
+ 'notification_key': notification.notification_key,
+ 'notification_type': notification.notification_type,
+ 'priority': notification.priority,
+ 'title': notification.title,
+ 'message': notification.message,
+ 'action_data': notification.action_data,
+ 'is_active': notification.is_active,
+ 'admin_only': notification.admin_only,
+ 'created_at': notification.created_at.isoformat() if notification.created_at else None,
+ }
+ else:
+ notification_data = notification
+
+ async_to_sync(channel_layer.group_send)(
+ 'updates',
+ {
+ 'type': 'update',
+ 'data': {
+ 'type': 'system_notification',
+ 'notification': notification_data,
+ }
+ }
+ )
+ logger.debug(f"Sent WebSocket notification: {notification_data.get('title', 'Unknown')}")
+ except Exception as e:
+ logger.error(f"Failed to send WebSocket notification: {e}")
+
+
+def send_notification_dismissed(notification_key):
+ """
+ Notify all connected clients that a notification was dismissed.
+ Useful for syncing dismissal state across multiple browser tabs/sessions.
+
+ Args:
+ notification_key: The unique key of the dismissed notification
+ """
+ try:
+ channel_layer = get_channel_layer()
+
+ async_to_sync(channel_layer.group_send)(
+ 'updates',
+ {
+ 'type': 'update',
+ 'data': {
+ 'type': 'notification_dismissed',
+ 'notification_key': notification_key,
+ }
+ }
+ )
+ except Exception as e:
+ logger.error(f"Failed to send notification dismissed event: {e}")
+
diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py
index b65ee2d0..7af6e939 100644
--- a/dispatcharr/settings.py
+++ b/dispatcharr/settings.py
@@ -236,6 +236,11 @@ CELERY_BEAT_SCHEDULE = {
"task": "apps.channels.tasks.maintain_recurring_recordings",
"schedule": 3600.0, # Once an hour ensure recurring schedules stay ahead
},
+ # Check for version updates daily
+ "check-version-updates": {
+ "task": "core.tasks.check_for_version_update",
+ "schedule": 86400.0, # Once every 24 hours
+ },
}
MEDIA_ROOT = BASE_DIR / "media"
diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx
index 034418f6..2d6cc0d9 100644
--- a/frontend/src/WebSocket.jsx
+++ b/frontend/src/WebSocket.jsx
@@ -845,6 +845,59 @@ export const WebsocketProvider = ({ children }) => {
break;
}
+ case 'system_notification': {
+ // Handle real-time system notifications (version updates, setting recommendations, etc.)
+ const notificationData = parsedEvent.data.notification;
+ if (notificationData) {
+ // Import and update the notifications store
+ const { default: useNotificationsStore } =
+ await import('./store/notifications');
+ useNotificationsStore
+ .getState()
+ .addNotification(notificationData);
+
+ // Show a toast notification for high priority items
+ if (
+ notificationData.priority === 'high' ||
+ notificationData.priority === 'critical'
+ ) {
+ const color =
+ notificationData.notification_type === 'version_update'
+ ? 'green'
+ : notificationData.notification_type === 'warning'
+ ? 'orange'
+ : 'blue';
+
+ notifications.show({
+ title: notificationData.title,
+ message: notificationData.message,
+ color,
+ autoClose: 10000,
+ });
+ }
+ }
+ break;
+ }
+
+ case 'notification_dismissed': {
+ // Handle notification dismissed from another session
+ const { notification_key } = parsedEvent.data;
+ if (notification_key) {
+ const { default: useNotificationsStore } =
+ await import('./store/notifications');
+ useNotificationsStore
+ .getState()
+ .dismissNotification(notification_key);
+ }
+ break;
+ }
+
+ case 'notifications_cleared': {
+ // Handle bulk notification clearing (e.g., when version is updated)
+ API.getNotifications();
+ break;
+ }
+
default:
console.error(
`Unknown websocket event type: ${parsedEvent.data?.type}`
diff --git a/frontend/src/api.js b/frontend/src/api.js
index a831ad22..676e97d8 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -2890,4 +2890,107 @@ export default class API {
errorNotification('Failed to retrieve system events', e);
}
}
+
+ // ─────────────────────────────
+ // System Notifications
+ // ─────────────────────────────
+
+ /**
+ * Get all active notifications for the current user
+ * @param {boolean} includeDismissed - Whether to include already dismissed notifications
+ */
+ static async getNotifications(includeDismissed = false) {
+ try {
+ const params = new URLSearchParams();
+ if (includeDismissed) {
+ params.append('include_dismissed', 'true');
+ }
+ const response = await request(
+ `${host}/api/core/notifications/?${params.toString()}`
+ );
+
+ // Update the store with fetched notifications
+ const { default: useNotificationsStore } = await import(
+ './store/notifications'
+ );
+ useNotificationsStore.getState().setNotifications(response.notifications);
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to retrieve notifications', e);
+ }
+ }
+
+ // Get unread notification count
+ static async getNotificationCount() {
+ try {
+ const response = await request(`${host}/api/core/notifications/count/`);
+
+ // Update the store with the count
+ const { default: useNotificationsStore } = await import(
+ './store/notifications'
+ );
+ useNotificationsStore.getState().setUnreadCount(response.unread_count);
+
+ return response;
+ } catch (e) {
+ // Silent fail for count - not critical
+ console.error('Failed to get notification count:', e);
+ return { unread_count: 0 };
+ }
+ }
+
+ /**
+ * Dismiss a specific notification
+ * @param {number} notificationId - The notification ID to dismiss
+ * @param {string} actionTaken - Optional action taken (e.g., 'applied', 'ignored')
+ */
+ static async dismissNotification(notificationId, actionTaken = null) {
+ try {
+ const body = {};
+ if (actionTaken) {
+ body.action_taken = actionTaken;
+ }
+
+ const response = await request(
+ `${host}/api/core/notifications/${notificationId}/dismiss/`,
+ {
+ method: 'POST',
+ body,
+ }
+ );
+
+ // Update the store
+ const { default: useNotificationsStore } = await import(
+ './store/notifications'
+ );
+ useNotificationsStore.getState().dismissNotification(response.notification_key);
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to dismiss notification', e);
+ }
+ }
+
+ // Dismiss all notifications
+ static async dismissAllNotifications() {
+ try {
+ const response = await request(
+ `${host}/api/core/notifications/dismiss-all/`,
+ {
+ method: 'POST',
+ }
+ );
+
+ // Update the store
+ const { default: useNotificationsStore } = await import(
+ './store/notifications'
+ );
+ useNotificationsStore.getState().dismissAllNotifications();
+
+ return response;
+ } catch (e) {
+ errorNotification('Failed to dismiss all notifications', e);
+ }
+ }
}
diff --git a/frontend/src/components/NotificationCenter.jsx b/frontend/src/components/NotificationCenter.jsx
new file mode 100644
index 00000000..f7da56ad
--- /dev/null
+++ b/frontend/src/components/NotificationCenter.jsx
@@ -0,0 +1,429 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ ActionIcon,
+ Badge,
+ Box,
+ Button,
+ Card,
+ Divider,
+ Group,
+ Indicator,
+ Popover,
+ ScrollArea,
+ Stack,
+ Text,
+ ThemeIcon,
+ Tooltip,
+ useMantineTheme,
+} from '@mantine/core';
+import {
+ Bell,
+ Check,
+ CheckCheck,
+ Download,
+ ExternalLink,
+ Info,
+ Settings,
+ AlertTriangle,
+ Megaphone,
+ X,
+ Eye,
+ EyeOff,
+ ArrowRight,
+} from 'lucide-react';
+import useNotificationsStore from '../store/notifications';
+import API from '../api';
+
+// Get icon for notification type
+const getNotificationIcon = (type) => {
+ switch (type) {
+ case 'version_update':
+ return