feat: 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
This commit is contained in:
SergeantPanda 2026-02-03 09:24:02 -06:00
parent da598d3b33
commit b01eb9585c
20 changed files with 2041 additions and 49 deletions

View file

@ -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)

View file

@ -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'),

View file

@ -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
})

View file

@ -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}")

View file

@ -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

View file

@ -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"
}
]
}

View file

@ -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')},
},
),
]

View file

@ -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}"

View file

@ -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']

View file

@ -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}")

View file

@ -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

View file

@ -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}")

View file

@ -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"

View file

@ -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}`

View file

@ -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);
}
}
}

View file

@ -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 <Download size={16} />;
case 'setting_recommendation':
return <Settings size={16} />;
case 'announcement':
return <Megaphone size={16} />;
case 'warning':
return <AlertTriangle size={16} />;
case 'info':
default:
return <Info size={16} />;
}
};
// Get color for notification priority
const getPriorityColor = (priority) => {
switch (priority) {
case 'critical':
return 'red';
case 'high':
return 'orange';
case 'normal':
return 'blue';
case 'low':
default:
return 'gray';
}
};
// Get color for notification type
const getTypeColor = (type) => {
switch (type) {
case 'version_update':
return 'green';
case 'setting_recommendation':
return 'blue';
case 'announcement':
return 'violet';
case 'warning':
return 'orange';
case 'info':
default:
return 'gray';
}
};
// Individual notification item component
const NotificationItem = ({ notification, onDismiss, onAction, onClose }) => {
const theme = useMantineTheme();
const navigate = useNavigate();
const typeColor = getTypeColor(notification.notification_type);
const priorityColor = getPriorityColor(notification.priority);
const isDismissed = notification.is_dismissed;
const handleDismiss = (e) => {
e.stopPropagation();
onDismiss(notification.id, 'dismissed');
};
const handleAction = () => {
// Handle action_url from action_data
const actionUrl = notification.action_data?.action_url;
const releaseUrl = notification.action_data?.release_url;
if (actionUrl) {
// Internal navigation
onClose(); // Close the popover
navigate(actionUrl);
} else if (releaseUrl) {
// External link
window.open(releaseUrl, '_blank');
}
if (onAction) {
onAction(notification);
}
};
return (
<Card
padding="sm"
radius="md"
withBorder
style={{
borderLeft: `3px solid ${theme.colors[priorityColor][5]}`,
backgroundColor:
notification.priority === 'critical'
? theme.colors.red[9] + '10'
: undefined,
opacity: isDismissed ? 0.6 : 1,
position: 'relative',
}}
>
{/* Dismiss button for non-setting notifications (only if not already dismissed) */}
{notification.notification_type !== 'setting_recommendation' &&
!isDismissed && (
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={handleDismiss}
style={{ position: 'absolute', top: 8, right: 8 }}
>
<X size={14} />
</ActionIcon>
)}
<Group wrap="nowrap" align="flex-start" gap="sm">
<ThemeIcon color={typeColor} variant="light" size="md" radius="xl">
{getNotificationIcon(notification.notification_type)}
</ThemeIcon>
<Box style={{ flex: 1 }}>
<Group gap="xs" mb={4}>
<Text size="sm" fw={600} lineClamp={1}>
{notification.title}
</Text>
{isDismissed && (
<Badge size="xs" color="gray" variant="light">
Dismissed
</Badge>
)}
{notification.priority === 'high' ||
notification.priority === 'critical' ? (
<Badge size="xs" color={priorityColor} variant="filled">
{notification.priority}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" lineClamp={5}>
{notification.message}
</Text>
{/* Action buttons for specific notification types */}
{notification.notification_type === 'version_update' &&
notification.action_data?.release_url && (
<Button
size="xs"
variant="light"
color="green"
mt="xs"
leftSection={<ExternalLink size={12} />}
onClick={handleAction}
>
View Release
</Button>
)}
{/* Generic action button for notifications with action_url/action_text */}
{notification.action_data?.action_url &&
notification.action_data?.action_text && (
<Button
size="xs"
variant="light"
color={typeColor}
mt="xs"
rightSection={<ArrowRight size={12} />}
onClick={handleAction}
>
{notification.action_data.action_text}
</Button>
)}
{notification.notification_type === 'setting_recommendation' &&
!notification.action_data?.action_url && (
<Group gap="xs" mt="xs">
<Button
size="xs"
variant="light"
color="blue"
onClick={() => {
onDismiss(notification.id, 'applied');
// Navigate to settings or apply the setting
if (onAction) onAction(notification);
}}
>
Apply
</Button>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={handleDismiss}
>
Ignore
</Button>
</Group>
)}
</Box>
</Group>
<Text size="xs" c="dimmed" mt="xs" ta="right">
{new Date(notification.created_at).toLocaleDateString()}
</Text>
</Card>
);
};
// Main notification center component with bell icon and popover
const NotificationCenter = ({ onSettingAction }) => {
const [opened, setOpened] = useState(false);
const [showDismissed, setShowDismissed] = useState(false);
const notifications = useNotificationsStore((s) => s.notifications);
const unreadCount = useNotificationsStore((s) => s.unreadCount);
const getUnreadNotifications = useNotificationsStore(
(s) => s.getUnreadNotifications
);
// Fetch notifications on mount and periodically
const fetchNotifications = useCallback(async () => {
try {
await API.getNotifications(showDismissed);
} catch (error) {
console.error('Failed to fetch notifications:', error);
}
}, [showDismissed]);
useEffect(() => {
fetchNotifications();
// Refresh notifications every 5 minutes
const interval = setInterval(fetchNotifications, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [fetchNotifications]);
const handleDismiss = async (notificationId, actionTaken = null) => {
try {
await API.dismissNotification(notificationId, actionTaken);
} catch (error) {
console.error('Failed to dismiss notification:', error);
}
};
const handleDismissAll = async () => {
try {
await API.dismissAllNotifications();
} catch (error) {
console.error('Failed to dismiss all notifications:', error);
}
};
const handleAction = (notification) => {
if (
notification.notification_type === 'setting_recommendation' &&
onSettingAction
) {
onSettingAction(notification);
}
};
const unreadNotifications = getUnreadNotifications();
const displayedNotifications = showDismissed
? notifications
: unreadNotifications;
return (
<Popover
opened={opened}
onChange={setOpened}
width={380}
position="bottom-end"
shadow="lg"
withArrow
>
<Popover.Target>
<Indicator
color="red"
size={16}
label={unreadCount > 9 ? '9+' : unreadCount}
disabled={unreadCount === 0}
offset={4}
processing={unreadCount > 0}
>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={() => setOpened((o) => !o)}
aria-label="Notifications"
>
<Bell size={20} />
</ActionIcon>
</Indicator>
</Popover.Target>
<Popover.Dropdown p={0}>
{/* Header */}
<Group justify="space-between" p="sm" pb="xs">
<Group gap="xs">
<Text fw={600} size="sm">
Notifications
</Text>
{unreadCount > 0 && (
<Badge size="sm" color="blue" variant="light">
{unreadCount} new
</Badge>
)}
</Group>
<Group gap="xs">
<Tooltip
label={showDismissed ? 'Hide dismissed' : 'Show dismissed'}
>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setShowDismissed((prev) => !prev)}
>
{showDismissed ? <EyeOff size={16} /> : <Eye size={16} />}
</ActionIcon>
</Tooltip>
{unreadCount > 0 && (
<Tooltip label="Mark all as read">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={handleDismissAll}
>
<CheckCheck size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
<Divider />
{/* Notification list */}
<ScrollArea.Autosize mah={400} type="auto" offsetScrollbars>
{displayedNotifications.length === 0 ? (
<Box p="lg" ta="center">
<ThemeIcon
color="gray"
variant="light"
size="xl"
radius="xl"
mb="sm"
>
<Check size={20} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{showDismissed
? 'No dismissed notifications'
: 'All caught up!'}
</Text>
<Text size="xs" c="dimmed">
{showDismissed
? 'Dismissed notifications appear here'
: 'No new notifications'}
</Text>
</Box>
) : (
<Stack gap="xs" p="xs">
{displayedNotifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onDismiss={handleDismiss}
onAction={handleAction}
onClose={() => setOpened(false)}
/>
))}
</Stack>
)}
</ScrollArea.Autosize>
{/* Footer with info text */}
{!showDismissed &&
notifications.length > unreadNotifications.length && (
<>
<Divider />
<Box p="xs" ta="center">
<Text size="xs" c="dimmed">
{notifications.length - unreadNotifications.length} dismissed
notification
{notifications.length - unreadNotifications.length !== 1
? 's'
: ''}
</Text>
</Box>
</>
)}
</Popover.Dropdown>
</Popover>
);
};
export default NotificationCenter;

View file

@ -36,6 +36,7 @@ import useSettingsStore from '../store/settings';
import useAuthStore from '../store/auth';
import { USER_LEVELS } from '../constants';
import UserForm from './forms/User';
import NotificationCenter from './NotificationCenter';
const NavLink = ({ item, isActive, collapsed }) => {
return (
@ -232,7 +233,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
}}
>
{isAuthenticated && (
<Group>
<Stack gap="sm">
{!collapsed && (
<TextInput
label="Public IP"
@ -262,34 +263,54 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
/>
)}
<Avatar src="" radius="xl" />
{!collapsed && authUser && (
<Group
style={{
flex: 1,
justifyContent: 'space-between',
whiteSpace: 'nowrap',
}}
gap="xs"
style={{ justifyContent: 'space-between', width: '100%' }}
>
<UnstyledButton onClick={() => setUserFormOpen(true)}>
{authUser.first_name || authUser.username}
</UnstyledButton>
<Group gap="xs">
<Avatar src="" radius="xl" />
<UnstyledButton onClick={() => setUserFormOpen(true)}>
{authUser.first_name || authUser.username}
</UnstyledButton>
</Group>
<ActionIcon variant="transparent" color="white" size="sm">
<LogOut onClick={logout} />
</ActionIcon>
</Group>
)}
</Group>
{collapsed && (
<Group gap="xs">
<Avatar src="" radius="xl" />
</Group>
)}
</Stack>
)}
</Box>
{/* Version is always shown when sidebar is expanded, regardless of auth status */}
{/* Version and Notification */}
{!collapsed && (
<Text size="xs" style={{ padding: '0 16px 16px' }} c="dimmed">
v{appVersion?.version || '0.0.0'}
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
</Text>
<Group
gap="xs"
style={{ padding: '0 16px 16px', justifyContent: 'space-between' }}
>
<Text size="xs" c="dimmed">
v{appVersion?.version || '0.0.0'}
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
</Text>
{isAuthenticated && <NotificationCenter />}
</Group>
)}
{collapsed && isAuthenticated && (
<Box
style={{
padding: '0 16px 16px',
display: 'flex',
justifyContent: 'center',
}}
>
<NotificationCenter />
</Box>
)}
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />

View file

@ -1,4 +1,5 @@
import React, { Suspense, useState } from 'react';
import React, { Suspense, useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import {
Accordion,
AccordionControl,
@ -7,48 +8,64 @@ import {
Box,
Center,
Text,
Loader
Loader,
} from '@mantine/core';
const UserAgentsTable = React.lazy(() =>
import('../components/tables/UserAgentsTable.jsx'));
const StreamProfilesTable = React.lazy(() =>
import('../components/tables/StreamProfilesTable.jsx'));
const BackupManager = React.lazy(() =>
import('../components/backups/BackupManager.jsx'));
const UserAgentsTable = React.lazy(
() => import('../components/tables/UserAgentsTable.jsx')
);
const StreamProfilesTable = React.lazy(
() => import('../components/tables/StreamProfilesTable.jsx')
);
const BackupManager = React.lazy(
() => import('../components/backups/BackupManager.jsx')
);
import useAuthStore from '../store/auth';
import { USER_LEVELS } from '../constants';
import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
const NetworkAccessForm = React.lazy(() =>
import('../components/forms/settings/NetworkAccessForm.jsx'));
const ProxySettingsForm = React.lazy(() =>
import('../components/forms/settings/ProxySettingsForm.jsx'));
const StreamSettingsForm = React.lazy(() =>
import('../components/forms/settings/StreamSettingsForm.jsx'));
const DvrSettingsForm = React.lazy(() =>
import('../components/forms/settings/DvrSettingsForm.jsx'));
const SystemSettingsForm = React.lazy(() =>
import('../components/forms/settings/SystemSettingsForm.jsx'));
const NetworkAccessForm = React.lazy(
() => import('../components/forms/settings/NetworkAccessForm.jsx')
);
const ProxySettingsForm = React.lazy(
() => import('../components/forms/settings/ProxySettingsForm.jsx')
);
const StreamSettingsForm = React.lazy(
() => import('../components/forms/settings/StreamSettingsForm.jsx')
);
const DvrSettingsForm = React.lazy(
() => import('../components/forms/settings/DvrSettingsForm.jsx')
);
const SystemSettingsForm = React.lazy(
() => import('../components/forms/settings/SystemSettingsForm.jsx')
);
const SettingsPage = () => {
const authUser = useAuthStore((s) => s.user);
const location = useLocation();
const [accordianValue, setAccordianValue] = useState(null);
const [accordianValue, setAccordianValue] = useState('ui-settings');
// Handle hash navigation to open specific accordion
useEffect(() => {
const hash = location.hash.replace('#', '');
if (hash) {
setAccordianValue(hash);
}
}, [location.hash]);
return (
<Center p={10}>
<Box w={'100%'} maw={800}>
<Accordion
variant="separated"
defaultValue="ui-settings"
value={accordianValue}
onChange={setAccordianValue}
miw={400}
>
<AccordionItem value="ui-settings">
<AccordionControl>UI Settings</AccordionControl>
<AccordionPanel>
<UiSettingsForm
active={accordianValue === 'ui-settings'} />
<UiSettingsForm active={accordianValue === 'ui-settings'} />
</AccordionPanel>
</AccordionItem>
@ -60,7 +77,8 @@ const SettingsPage = () => {
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<DvrSettingsForm
active={accordianValue === 'dvr-settings'} />
active={accordianValue === 'dvr-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
@ -72,7 +90,8 @@ const SettingsPage = () => {
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<StreamSettingsForm
active={accordianValue === 'stream-settings'} />
active={accordianValue === 'stream-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
@ -84,7 +103,8 @@ const SettingsPage = () => {
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<SystemSettingsForm
active={accordianValue === 'system-settings'} />
active={accordianValue === 'system-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
@ -96,7 +116,8 @@ const SettingsPage = () => {
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<UserAgentsTable
active={accordianValue === 'user-agents'} />
active={accordianValue === 'user-agents'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
@ -108,7 +129,8 @@ const SettingsPage = () => {
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<StreamProfilesTable
active={accordianValue === 'stream-profiles'} />
active={accordianValue === 'stream-profiles'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
@ -127,7 +149,8 @@ const SettingsPage = () => {
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<NetworkAccessForm
active={accordianValue === 'network-access'} />
active={accordianValue === 'network-access'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
@ -141,7 +164,8 @@ const SettingsPage = () => {
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<ProxySettingsForm
active={accordianValue === 'proxy-settings'} />
active={accordianValue === 'proxy-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>

View file

@ -0,0 +1,111 @@
import { create } from 'zustand';
// Store for managing system notifications (version updates, recommended settings, announcements)
const useNotificationsStore = create((set, get) => ({
notifications: [],
unreadCount: 0,
isLoading: false,
error: null,
lastFetched: null,
// Set notifications directly (used by API layer)
setNotifications: (notifications) => {
const unreadCount = notifications.filter((n) => !n.is_dismissed).length;
set({
notifications,
unreadCount,
lastFetched: new Date().toISOString(),
});
},
// Add a new notification (e.g., from WebSocket)
addNotification: (notification) => {
set((state) => {
const exists = state.notifications.some(
(n) => n.notification_key === notification.notification_key
);
if (exists) {
// Update existing notification
return {
notifications: state.notifications.map((n) =>
n.notification_key === notification.notification_key
? { ...n, ...notification }
: n
),
};
}
// Add new notification
const newNotifications = [notification, ...state.notifications];
return {
notifications: newNotifications,
unreadCount: newNotifications.filter((n) => !n.is_dismissed).length,
};
});
},
// Mark a notification as dismissed locally
dismissNotification: (notificationKey) => {
set((state) => {
const notifications = state.notifications.map((n) =>
n.notification_key === notificationKey
? { ...n, is_dismissed: true }
: n
);
return {
notifications,
unreadCount: notifications.filter((n) => !n.is_dismissed).length,
};
});
},
// Mark all notifications as dismissed locally
dismissAllNotifications: () => {
set((state) => ({
notifications: state.notifications.map((n) => ({
...n,
is_dismissed: true,
})),
unreadCount: 0,
}));
},
// Remove a notification from the store
removeNotification: (notificationKey) => {
set((state) => {
const notifications = state.notifications.filter(
(n) => n.notification_key !== notificationKey
);
return {
notifications,
unreadCount: notifications.filter((n) => !n.is_dismissed).length,
};
});
},
// Update unread count
setUnreadCount: (count) => {
set({ unreadCount: count });
},
// Set loading state
setLoading: (isLoading) => {
set({ isLoading });
},
// Set error state
setError: (error) => {
set({ error });
},
// Get notifications by type
getNotificationsByType: (type) => {
return get().notifications.filter((n) => n.notification_type === type);
},
// Get unread notifications only
getUnreadNotifications: () => {
return get().notifications.filter((n) => !n.is_dismissed);
},
}));
export default useNotificationsStore;

View file

@ -34,6 +34,7 @@ dependencies = [
"django-filter",
"django-celery-beat",
"lxml==6.0.2",
"packaging",
]
[build-system]