mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Merged user statistics and acl manager
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
web_ui:
|
web_ui:
|
||||||
#image: ultradesu/outfleet:v2
|
|
||||||
image: outfleet:local
|
image: outfleet:local
|
||||||
container_name: outfleet-web
|
container_name: outfleet-web
|
||||||
build:
|
build:
|
||||||
@@ -18,17 +17,19 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
working_dir: /app
|
||||||
command: >
|
command: >
|
||||||
sh -c "sleep 1 &&
|
sh -c "sleep 1 &&
|
||||||
python manage.py makemigrations &&
|
python manage.py makemigrations &&
|
||||||
python manage.py migrate &&
|
python manage.py migrate &&
|
||||||
python manage.py create_admin &&
|
python manage.py create_admin &&
|
||||||
python manage.py runserver 0.0.0.0:8000"
|
python manage.py runserver 0.0.0.0:8000"
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
image: outfleet:local
|
image: outfleet:local
|
||||||
container_name: outfleet-worker
|
container_name: outfleet-worker
|
||||||
# volumes:
|
|
||||||
# - .:/app
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
environment:
|
environment:
|
||||||
@@ -41,13 +42,15 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
working_dir: /app
|
||||||
command: >
|
command: >
|
||||||
sh -c "sleep 3 && celery -A mysite worker"
|
sh -c "sleep 3 && celery -A mysite worker"
|
||||||
|
|
||||||
beat:
|
beat:
|
||||||
image: outfleet:local
|
image: outfleet:local
|
||||||
container_name: outfleet-beat
|
container_name: outfleet-beat
|
||||||
# volumes:
|
|
||||||
# - .:/app
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
environment:
|
environment:
|
||||||
@@ -60,6 +63,9 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
working_dir: /app
|
||||||
command: >
|
command: >
|
||||||
sh -c "sleep 3 && celery -A mysite beat"
|
sh -c "sleep 3 && celery -A mysite beat"
|
||||||
|
|
||||||
@@ -93,3 +99,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|
||||||
|
@@ -28,7 +28,7 @@ from celery.schedules import crontab
|
|||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
'update-user-statistics': {
|
'update-user-statistics': {
|
||||||
'task': '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': {
|
'cleanup-task-logs': {
|
||||||
'task': 'cleanup_task_logs',
|
'task': 'cleanup_task_logs',
|
||||||
|
274
vpn/admin.py
274
vpn/admin.py
@@ -25,102 +25,6 @@ from .server_plugins import (
|
|||||||
OutlineServer,
|
OutlineServer,
|
||||||
OutlineServerAdmin)
|
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(
|
|
||||||
'<a href="{}" target="_blank" style="color: #2563eb; text-decoration: none; font-family: monospace;">{}</a>',
|
|
||||||
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'<span style="color: {color}; font-weight: bold;">{formatted_date}</span>'
|
|
||||||
f'<br><small style="color: {color};">{relative}</small>'
|
|
||||||
)
|
|
||||||
|
|
||||||
@admin.display(description='Daily Usage Chart')
|
|
||||||
def daily_usage_chart(self, obj):
|
|
||||||
if not obj.daily_usage:
|
|
||||||
return mark_safe('<span style="color: #9ca3af;">No data</span>')
|
|
||||||
|
|
||||||
# Create a simple ASCII-style chart
|
|
||||||
max_val = max(obj.daily_usage) if obj.daily_usage else 1
|
|
||||||
chart_html = '<div style="font-family: monospace; background: #f9fafb; padding: 10px; border-radius: 4px;">'
|
|
||||||
chart_html += f'<div style="margin-bottom: 5px; font-size: 12px; color: #6b7280;">Last 30 days (max: {max_val})</div>'
|
|
||||||
|
|
||||||
# Create bar chart
|
|
||||||
chart_html += '<div style="display: flex; align-items: end; gap: 1px; height: 40px;">'
|
|
||||||
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'<div style="background: {color}; width: 3px; height: {height_percent}%; min-height: 2px;" title="{day_count} connections"></div>'
|
|
||||||
|
|
||||||
chart_html += '</div></div>'
|
|
||||||
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)
|
@admin.register(TaskExecutionLog)
|
||||||
class TaskExecutionLogAdmin(admin.ModelAdmin):
|
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)
|
@admin.register(ACLLink)
|
||||||
class ACLLinkAdmin(admin.ModelAdmin):
|
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')
|
list_filter = ('acl__server__name', 'acl__server__server_type', LastAccessFilter, 'acl__user__username')
|
||||||
search_fields = ('link', 'comment', 'acl__user__username', 'acl__server__name')
|
search_fields = ('link', 'comment', 'acl__user__username', 'acl__server__name')
|
||||||
list_per_page = 100
|
list_per_page = 100
|
||||||
actions = ['delete_selected_links']
|
actions = ['delete_selected_links', 'update_statistics_action']
|
||||||
list_select_related = ('acl__user', 'acl__server')
|
list_select_related = ('acl__user', 'acl__server')
|
||||||
|
|
||||||
def get_queryset(self, request):
|
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 obj.comment[:50] + '...' if len(obj.comment) > 50 else obj.comment
|
||||||
return '-'
|
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'<div style="font-size: 12px;">'
|
||||||
|
f'<span style="color: {color}; font-weight: bold;">✨ {stats.total_connections} total</span><br>'
|
||||||
|
f'<span style="color: #6b7280;">📅 {stats.recent_connections} last 30d</span>'
|
||||||
|
f'</div>'
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
return mark_safe('<span style="color: #dc2626; font-size: 12px;">No cache</span>')
|
||||||
|
|
||||||
|
@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('<span style="color: #9ca3af; font-size: 11px;">No data</span>')
|
||||||
|
|
||||||
|
# Create mini chart
|
||||||
|
max_val = max(stats.daily_usage) if stats.daily_usage else 1
|
||||||
|
chart_html = '<div style="display: flex; align-items: end; gap: 1px; height: 20px; width: 60px;">'
|
||||||
|
|
||||||
|
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'<div style="background: {color}; width: 2px; height: {height_percent}%; min-height: 1px;" title="{day_count} connections"></div>'
|
||||||
|
|
||||||
|
chart_html += '</div>'
|
||||||
|
return mark_safe(chart_html)
|
||||||
|
except:
|
||||||
|
return mark_safe('<span style="color: #dc2626; font-size: 11px;">-</span>')
|
||||||
|
|
||||||
@admin.display(description='Last Access', ordering='last_access_time')
|
@admin.display(description='Last Access', ordering='last_access_time')
|
||||||
def last_access_display(self, obj):
|
def last_access_display(self, obj):
|
||||||
if obj.last_access_time:
|
if obj.last_access_time:
|
||||||
@@ -883,10 +849,10 @@ class ACLLinkAdmin(admin.ModelAdmin):
|
|||||||
relative = 'Recently'
|
relative = 'Recently'
|
||||||
|
|
||||||
return mark_safe(
|
return mark_safe(
|
||||||
f'<span style="color: {color}; font-weight: bold;">{formatted_date}</span>'
|
f'<span style="color: {color}; font-weight: bold; font-size: 12px;">{formatted_date}</span>'
|
||||||
f'<br><small style="color: {color};">{relative}</small>'
|
f'<br><small style="color: {color}; font-size: 10px;">{relative}</small>'
|
||||||
)
|
)
|
||||||
return mark_safe('<span style="color: #dc2626; font-weight: bold;">Never</span>')
|
return mark_safe('<span style="color: #dc2626; font-weight: bold; font-size: 12px;">Never</span>')
|
||||||
|
|
||||||
@admin.display(description='Created', ordering='acl__created_at')
|
@admin.display(description='Created', ordering='acl__created_at')
|
||||||
def created_display(self, obj):
|
def created_display(self, obj):
|
||||||
@@ -899,6 +865,31 @@ class ACLLinkAdmin(admin.ModelAdmin):
|
|||||||
self.message_user(request, f'Successfully deleted {count} ACL link(s).')
|
self.message_user(request, f'Successfully deleted {count} ACL link(s).')
|
||||||
delete_selected_links.short_description = "Delete selected ACL links"
|
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):
|
def get_actions(self, request):
|
||||||
"""Remove default delete action and keep only custom one"""
|
"""Remove default delete action and keep only custom one"""
|
||||||
actions = super().get_actions(request)
|
actions = super().get_actions(request)
|
||||||
@@ -916,7 +907,16 @@ class ACLLinkAdmin(admin.ModelAdmin):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def changelist_view(self, request, extra_context=None):
|
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 {}
|
extra_context = extra_context or {}
|
||||||
|
|
||||||
# Get queryset for statistics
|
# Get queryset for statistics
|
||||||
@@ -927,15 +927,79 @@ class ACLLinkAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
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(
|
old_links = queryset.filter(
|
||||||
Q(last_access_time__lt=three_months_ago) | Q(last_access_time__isnull=True)
|
Q(last_access_time__lt=three_months_ago) | Q(last_access_time__isnull=True)
|
||||||
).count()
|
).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({
|
extra_context.update({
|
||||||
'total_links': total_links,
|
'total_links': total_links,
|
||||||
'never_accessed': never_accessed,
|
'never_accessed': never_accessed,
|
||||||
'old_links': old_links,
|
'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)
|
return super().changelist_view(request, extra_context)
|
||||||
@@ -947,8 +1011,8 @@ class ACLLinkAdmin(admin.ModelAdmin):
|
|||||||
if order_var:
|
if order_var:
|
||||||
try:
|
try:
|
||||||
field_index = int(order_var.lstrip('-'))
|
field_index = int(order_var.lstrip('-'))
|
||||||
# Check if this corresponds to the last_access column (index 4 in list_display)
|
# Check if this corresponds to the last_access column (index 6 in list_display)
|
||||||
if field_index == 4: # last_access_display is at index 4
|
if field_index == 6: # last_access_display is at index 6
|
||||||
if order_var.startswith('-'):
|
if order_var.startswith('-'):
|
||||||
return ['-last_access_time']
|
return ['-last_access_time']
|
||||||
else:
|
else:
|
||||||
|
@@ -1,26 +1,47 @@
|
|||||||
{% extends "admin/change_list.html" %}
|
{% extends "admin/change_list.html" %}
|
||||||
|
|
||||||
{% block content_title %}
|
{% block content_title %}
|
||||||
<h1>ACL Links</h1>
|
<h1 class="h4 m-0 pr-3 mr-3 border-right">ACL Links & User Statistics</h1>
|
||||||
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin: 16px 0; display: flex; gap: 20px; flex-wrap: wrap;">
|
{% endblock %}
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
|
||||||
<span style="background: #3b82f6; color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold;">📊</span>
|
{% block content %}
|
||||||
<span><strong>Total Links:</strong> {{ total_links|default:0 }}</span>
|
<!-- Compact Statistics Panel -->
|
||||||
</div>
|
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; margin: 0 0 16px 0;">
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<div style="display: flex; gap: 16px; flex-wrap: wrap; align-items: center;">
|
||||||
<span style="background: #dc2626; color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold;">❌</span>
|
<!-- Key Metrics -->
|
||||||
<span><strong>Never Accessed:</strong> {{ never_accessed|default:0 }}</span>
|
<div style="background: #3b82f6; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
|
||||||
</div>
|
<div style="font-size: 18px; font-weight: bold;">{{ total_links|default:0 }}</div>
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<div style="font-size: 11px; opacity: 0.9;">Links</div>
|
||||||
<span style="background: #f97316; color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold;">⚠️</span>
|
</div>
|
||||||
<span><strong>Unused (3+ months):</strong> {{ old_links|default:0 }}</span>
|
<div style="background: #10b981; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
|
||||||
</div>
|
<div style="font-size: 18px; font-weight: bold;">{{ total_uses|default:0|floatformat:0 }}</div>
|
||||||
{% if old_links > 0 %}
|
<div style="font-size: 11px; opacity: 0.9;">Total Uses</div>
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-left: auto;">
|
</div>
|
||||||
<span style="background: #f59e0b; color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
|
<div style="background: #8b5cf6; color: white; padding: 8px 12px; border-radius: 4px; min-width: 100px; text-align: center;">
|
||||||
💡 Use "Last Access" filter to find old links
|
<div style="font-size: 18px; font-weight: bold;">{{ recent_uses|default:0|floatformat:0 }}</div>
|
||||||
</span>
|
<div style="font-size: 11px; opacity: 0.9;">Recent (30d)</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
<!-- Status indicators -->
|
||||||
|
<div style="margin-left: 20px; display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
|
{% if never_accessed > 0 %}
|
||||||
|
<span style="background: #dc2626; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">❌ {{ never_accessed }} never used</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if old_links > 0 %}
|
||||||
|
<span style="background: #f97316; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">⚠️ {{ old_links }} old</span>
|
||||||
|
{% endif %}
|
||||||
|
<span style="background: #059669; color: white; padding: 4px 8px; border-radius: 3px; font-size: 12px;">✅ {{ active_links|default:0 }} active</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div style="margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<a href="?last_access_status=never" style="background: #dc2626; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">🔍 Never Used</a>
|
||||||
|
<a href="?last_access_status=old" style="background: #f97316; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">⏰ Old</a>
|
||||||
|
<a href="?last_access_status=week" style="background: #10b981; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">📅 Recent</a>
|
||||||
|
<a href="?" style="background: #6b7280; color: white; padding: 4px 8px; border-radius: 3px; text-decoration: none; font-size: 11px;">🔄 Clear</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ block.super }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -470,7 +470,7 @@
|
|||||||
{% if total_connections == 0 and total_links > 0 %}
|
{% if total_connections == 0 and total_links > 0 %}
|
||||||
<p>📊 Statistics cache is empty. Run update in Admin → Task Execution Logs</p>
|
<p>📊 Statistics cache is empty. Run update in Admin → Task Execution Logs</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>📊 Statistics are updated every 3 hours and show your connection history</p>
|
<p>📊 Statistics are updated every 5 minutes and show your connection history</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user