diff --git a/docker-compose.yaml b/docker-compose.yaml index 39031f3..30f4d3d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,5 @@ services: web_ui: - #image: ultradesu/outfleet:v2 image: outfleet:local container_name: outfleet-web build: @@ -18,17 +17,19 @@ services: condition: service_healthy redis: condition: service_healthy + volumes: + - .:/app + working_dir: /app command: > sh -c "sleep 1 && python manage.py makemigrations && python manage.py migrate && python manage.py create_admin && python manage.py runserver 0.0.0.0:8000" + worker: image: outfleet:local container_name: outfleet-worker -# volumes: -# - .:/app build: context: . environment: @@ -41,13 +42,15 @@ services: condition: service_healthy redis: condition: service_healthy + volumes: + - .:/app + working_dir: /app command: > sh -c "sleep 3 && celery -A mysite worker" + beat: image: outfleet:local container_name: outfleet-beat -# volumes: -# - .:/app build: context: . environment: @@ -60,6 +63,9 @@ services: condition: service_healthy redis: condition: service_healthy + volumes: + - .:/app + working_dir: /app command: > sh -c "sleep 3 && celery -A mysite beat" @@ -93,3 +99,4 @@ services: volumes: postgres_data: + diff --git a/mysite/settings.py b/mysite/settings.py index 9d86c7a..baabe40 100644 --- a/mysite/settings.py +++ b/mysite/settings.py @@ -28,7 +28,7 @@ from celery.schedules import crontab CELERY_BEAT_SCHEDULE = { 'update-user-statistics': { 'task': 'update_user_statistics', - 'schedule': crontab(minute=0, hour='*/3'), # Every 3 hours + 'schedule': crontab(minute='*/5'), # Every 5 minutes }, 'cleanup-task-logs': { 'task': 'cleanup_task_logs', diff --git a/vpn/admin.py b/vpn/admin.py index 68f5ff5..7108d39 100644 --- a/vpn/admin.py +++ b/vpn/admin.py @@ -25,102 +25,6 @@ from .server_plugins import ( OutlineServer, OutlineServerAdmin) -@admin.register(UserStatistics) -class UserStatisticsAdmin(admin.ModelAdmin): - list_display = ('user_display', 'server_name', 'link_display', 'total_connections', 'recent_connections', 'max_daily', 'updated_at_display') - list_filter = ('server_name', 'updated_at', 'user__username') - search_fields = ('user__username', 'server_name', 'acl_link_id') - readonly_fields = ('user', 'server_name', 'acl_link_id', 'total_connections', 'recent_connections', 'daily_usage_chart', 'max_daily', 'updated_at') - ordering = ('-updated_at', 'user__username', 'server_name') - list_per_page = 100 - - fieldsets = ( - ('Basic Information', { - 'fields': ('user', 'server_name', 'acl_link_id') - }), - ('Statistics', { - 'fields': ('total_connections', 'recent_connections', 'max_daily') - }), - ('Usage Chart', { - 'fields': ('daily_usage_chart',) - }), - ('Metadata', { - 'fields': ('updated_at',) - }), - ) - - @admin.display(description='User', ordering='user__username') - def user_display(self, obj): - return obj.user.username - - @admin.display(description='Link', ordering='acl_link_id') - def link_display(self, obj): - if obj.acl_link_id: - link_url = f"{EXTERNAL_ADDRESS}/ss/{obj.acl_link_id}#{obj.server_name}" - return format_html( - '{}', - link_url, obj.acl_link_id[:12] + '...' if len(obj.acl_link_id) > 12 else obj.acl_link_id - ) - return '-' - - @admin.display(description='Last Updated', ordering='updated_at') - def updated_at_display(self, obj): - from django.utils import timezone - local_time = localtime(obj.updated_at) - now = timezone.now() - diff = now - obj.updated_at - - formatted_date = local_time.strftime('%Y-%m-%d %H:%M') - - # Color coding based on freshness - if diff.total_seconds() < 3600: # Less than 1 hour - color = '#16a34a' # green - relative = 'Fresh' - elif diff.total_seconds() < 7200: # Less than 2 hours - color = '#eab308' # yellow - relative = f'{int(diff.total_seconds() // 3600)}h ago' - else: - color = '#dc2626' # red - relative = f'{diff.days}d ago' if diff.days > 0 else f'{int(diff.total_seconds() // 3600)}h ago' - - return mark_safe( - f'{formatted_date}' - f'
{relative}' - ) - - @admin.display(description='Daily Usage Chart') - def daily_usage_chart(self, obj): - if not obj.daily_usage: - return mark_safe('No data') - - # Create a simple ASCII-style chart - max_val = max(obj.daily_usage) if obj.daily_usage else 1 - chart_html = '
' - chart_html += f'
Last 30 days (max: {max_val})
' - - # Create bar chart - chart_html += '
' - for day_count in obj.daily_usage: - if max_val > 0: - height_percent = (day_count / max_val) * 100 - else: - height_percent = 0 - - color = '#4ade80' if day_count > 0 else '#e5e7eb' - chart_html += f'
' - - chart_html += '
' - return mark_safe(chart_html) - - def has_add_permission(self, request): - return False - - def has_change_permission(self, request, obj=None): - return False - - def has_delete_permission(self, request, obj=None): - return True # Allow deletion to clear cache - @admin.register(TaskExecutionLog) class TaskExecutionLogAdmin(admin.ModelAdmin): @@ -807,13 +711,15 @@ class ACLAdmin(admin.ModelAdmin): ) +# Note: UserStatistics is not registered separately as admin model. +# All user statistics functionality is integrated into ACLLinkAdmin below. @admin.register(ACLLink) class ACLLinkAdmin(admin.ModelAdmin): - list_display = ('link_display', 'user_display', 'server_display', 'comment_display', 'last_access_display', 'created_display') + list_display = ('link_display', 'user_display', 'server_display', 'comment_display', 'stats_display', 'usage_chart_display', 'last_access_display', 'created_display') list_filter = ('acl__server__name', 'acl__server__server_type', LastAccessFilter, 'acl__user__username') search_fields = ('link', 'comment', 'acl__user__username', 'acl__server__name') list_per_page = 100 - actions = ['delete_selected_links'] + actions = ['delete_selected_links', 'update_statistics_action'] list_select_related = ('acl__user', 'acl__server') def get_queryset(self, request): @@ -848,6 +754,66 @@ class ACLLinkAdmin(admin.ModelAdmin): return obj.comment[:50] + '...' if len(obj.comment) > 50 else obj.comment return '-' + @admin.display(description='Statistics') + def stats_display(self, obj): + try: + from .models import UserStatistics + stats = UserStatistics.objects.get( + user=obj.acl.user, + server_name=obj.acl.server.name, + acl_link_id=obj.link + ) + + # Color coding based on usage + if stats.total_connections > 100: + color = '#16a34a' # green - high usage + elif stats.total_connections > 10: + color = '#eab308' # yellow - medium usage + elif stats.total_connections > 0: + color = '#f97316' # orange - low usage + else: + color = '#9ca3af' # gray - no usage + + return mark_safe( + f'
' + f'✨ {stats.total_connections} total
' + f'📅 {stats.recent_connections} last 30d' + f'
' + ) + except: + return mark_safe('No cache') + + @admin.display(description='30-day Chart') + def usage_chart_display(self, obj): + try: + from .models import UserStatistics + stats = UserStatistics.objects.get( + user=obj.acl.user, + server_name=obj.acl.server.name, + acl_link_id=obj.link + ) + + if not stats.daily_usage: + return mark_safe('No data') + + # Create mini chart + max_val = max(stats.daily_usage) if stats.daily_usage else 1 + chart_html = '
' + + for day_count in stats.daily_usage[-14:]: # Last 14 days for compact view + if max_val > 0: + height_percent = (day_count / max_val) * 100 + else: + height_percent = 0 + + color = '#4ade80' if day_count > 0 else '#e5e7eb' + chart_html += f'
' + + chart_html += '
' + return mark_safe(chart_html) + except: + return mark_safe('-') + @admin.display(description='Last Access', ordering='last_access_time') def last_access_display(self, obj): if obj.last_access_time: @@ -883,10 +849,10 @@ class ACLLinkAdmin(admin.ModelAdmin): relative = 'Recently' return mark_safe( - f'{formatted_date}' - f'
{relative}' + f'{formatted_date}' + f'
{relative}' ) - return mark_safe('Never') + return mark_safe('Never') @admin.display(description='Created', ordering='acl__created_at') def created_display(self, obj): @@ -898,6 +864,31 @@ class ACLLinkAdmin(admin.ModelAdmin): queryset.delete() self.message_user(request, f'Successfully deleted {count} ACL link(s).') delete_selected_links.short_description = "Delete selected ACL links" + + def update_statistics_action(self, request, queryset): + """Trigger comprehensive statistics update for all users and links""" + # This action doesn't require selected items + try: + from vpn.tasks import update_user_statistics + + # Start the statistics update task + task = update_user_statistics.delay() + + self.message_user( + request, + f'📊 Statistics update started successfully! Task ID: {task.id}. ' + f'This will recalculate usage statistics for all users and links. ' + f'Refresh this page in a few moments to see updated data.', + level=messages.SUCCESS + ) + + except Exception as e: + self.message_user( + request, + f'❌ Failed to start statistics update: {e}', + level=messages.ERROR + ) + update_statistics_action.short_description = "📊 Update all user statistics and usage data" def get_actions(self, request): """Remove default delete action and keep only custom one""" @@ -916,7 +907,16 @@ class ACLLinkAdmin(admin.ModelAdmin): return True def changelist_view(self, request, extra_context=None): - # Add summary statistics to the changelist + # Handle actions that don't require item selection + if 'action' in request.POST: + action = request.POST['action'] + if action == 'update_statistics_action': + # Call the action directly without queryset requirement + self.update_statistics_action(request, None) + # Return redirect to prevent AttributeError + return redirect(request.get_full_path()) + + # Add comprehensive statistics to the changelist extra_context = extra_context or {} # Get queryset for statistics @@ -927,15 +927,79 @@ class ACLLinkAdmin(admin.ModelAdmin): from django.utils import timezone from datetime import timedelta - three_months_ago = timezone.now() - timedelta(days=90) + from django.db.models import Count, Max, Min + + now = timezone.now() + one_week_ago = now - timedelta(days=7) + one_month_ago = now - timedelta(days=30) + three_months_ago = now - timedelta(days=90) + + # Access time statistics old_links = queryset.filter( Q(last_access_time__lt=three_months_ago) | Q(last_access_time__isnull=True) ).count() + recent_week = queryset.filter(last_access_time__gte=one_week_ago).count() + recent_month = queryset.filter(last_access_time__gte=one_month_ago).count() + + # Calculate comprehensive statistics from cache + try: + from .models import UserStatistics + from django.db import models + + # Total usage statistics + cached_stats = UserStatistics.objects.aggregate( + total_uses=models.Sum('total_connections'), + recent_uses=models.Sum('recent_connections'), + max_daily_peak=models.Max('max_daily') + ) + total_uses = cached_stats['total_uses'] or 0 + recent_uses = cached_stats['recent_uses'] or 0 + max_daily_peak = cached_stats['max_daily_peak'] or 0 + + # Server and user breakdown + server_stats = UserStatistics.objects.values('server_name').annotate( + total_connections=models.Sum('total_connections'), + link_count=models.Count('id') + ).order_by('-total_connections')[:5] # Top 5 servers + + user_stats = UserStatistics.objects.values('user__username').annotate( + total_connections=models.Sum('total_connections'), + link_count=models.Count('id') + ).order_by('-total_connections')[:5] # Top 5 users + + # Links with cache data count + cached_links_count = UserStatistics.objects.filter( + acl_link_id__isnull=False + ).count() + + except Exception as e: + total_uses = 0 + recent_uses = 0 + max_daily_peak = 0 + server_stats = [] + user_stats = [] + cached_links_count = 0 + + # Active vs inactive breakdown + active_links = total_links - never_accessed - old_links + if active_links < 0: + active_links = 0 + extra_context.update({ 'total_links': total_links, 'never_accessed': never_accessed, 'old_links': old_links, + 'active_links': active_links, + 'recent_week': recent_week, + 'recent_month': recent_month, + 'total_uses': total_uses, + 'recent_uses': recent_uses, + 'max_daily_peak': max_daily_peak, + 'server_stats': server_stats, + 'user_stats': user_stats, + 'cached_links_count': cached_links_count, + 'cache_coverage_percent': (cached_links_count * 100 // total_links) if total_links > 0 else 0, }) return super().changelist_view(request, extra_context) @@ -947,8 +1011,8 @@ class ACLLinkAdmin(admin.ModelAdmin): if order_var: try: field_index = int(order_var.lstrip('-')) - # Check if this corresponds to the last_access column (index 4 in list_display) - if field_index == 4: # last_access_display is at index 4 + # Check if this corresponds to the last_access column (index 6 in list_display) + if field_index == 6: # last_access_display is at index 6 if order_var.startswith('-'): return ['-last_access_time'] else: diff --git a/vpn/templates/admin/vpn/acllink/change_list.html b/vpn/templates/admin/vpn/acllink/change_list.html index 03ebd53..3575a94 100644 --- a/vpn/templates/admin/vpn/acllink/change_list.html +++ b/vpn/templates/admin/vpn/acllink/change_list.html @@ -1,26 +1,47 @@ {% extends "admin/change_list.html" %} {% block content_title %} -

ACL Links

-
-
- 📊 - Total Links: {{ total_links|default:0 }} -
-
- - Never Accessed: {{ never_accessed|default:0 }} -
-
- ⚠️ - Unused (3+ months): {{ old_links|default:0 }} -
- {% if old_links > 0 %} -
- - 💡 Use "Last Access" filter to find old links - -
- {% endif %} -
+

ACL Links & User Statistics

+{% endblock %} + +{% block content %} + +
+
+ +
+
{{ total_links|default:0 }}
+
Links
+
+
+
{{ total_uses|default:0|floatformat:0 }}
+
Total Uses
+
+
+
{{ recent_uses|default:0|floatformat:0 }}
+
Recent (30d)
+
+ + +
+ {% if never_accessed > 0 %} + ❌ {{ never_accessed }} never used + {% endif %} + {% if old_links > 0 %} + ⚠️ {{ old_links }} old + {% endif %} + ✅ {{ active_links|default:0 }} active +
+ + +
+ 🔍 Never Used + ⏰ Old + 📅 Recent + 🔄 Clear +
+
+
+ + {{ block.super }} {% endblock %} diff --git a/vpn/templates/vpn/user_portal.html b/vpn/templates/vpn/user_portal.html index eb58feb..a5b7ea3 100644 --- a/vpn/templates/vpn/user_portal.html +++ b/vpn/templates/vpn/user_portal.html @@ -470,7 +470,7 @@ {% if total_connections == 0 and total_links > 0 %}

📊 Statistics cache is empty. Run update in Admin → Task Execution Logs

{% else %} -

📊 Statistics are updated every 3 hours and show your connection history

+

📊 Statistics are updated every 5 minutes and show your connection history

{% endif %}