mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Improve server page
This commit is contained in:
212
vpn/admin.py
212
vpn/admin.py
@@ -247,11 +247,16 @@ class LastAccessFilter(admin.SimpleListFilter):
|
|||||||
class ServerAdmin(PolymorphicParentModelAdmin):
|
class ServerAdmin(PolymorphicParentModelAdmin):
|
||||||
base_model = Server
|
base_model = Server
|
||||||
child_models = (OutlineServer, WireguardServer)
|
child_models = (OutlineServer, WireguardServer)
|
||||||
list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'activity_summary', 'server_status_compact', 'registration_date')
|
list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'server_status_compact', 'registration_date')
|
||||||
search_fields = ('name', 'comment')
|
search_fields = ('name', 'comment')
|
||||||
list_filter = ('server_type', )
|
list_filter = ('server_type', )
|
||||||
actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers']
|
actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers']
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
'all': ('admin/css/vpn_server_admin.css',)
|
||||||
|
}
|
||||||
|
|
||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
urls = super().get_urls()
|
urls = super().get_urls()
|
||||||
custom_urls = [
|
custom_urls = [
|
||||||
@@ -619,32 +624,44 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
|||||||
|
|
||||||
@admin.display(description='Users & Links')
|
@admin.display(description='Users & Links')
|
||||||
def user_stats(self, obj):
|
def user_stats(self, obj):
|
||||||
"""Display user count and active links statistics"""
|
"""Display user count and active links statistics (optimized)"""
|
||||||
try:
|
try:
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
user_count = obj.user_count if hasattr(obj, 'user_count') else 0
|
user_count = obj.user_count if hasattr(obj, 'user_count') else 0
|
||||||
|
|
||||||
# Get total links count
|
# Use prefetched data if available
|
||||||
total_links = ACLLink.objects.filter(acl__server=obj).count()
|
if hasattr(obj, 'acl_set'):
|
||||||
|
all_links = []
|
||||||
|
for acl in obj.acl_set.all():
|
||||||
|
if hasattr(acl, 'links') and hasattr(acl.links, 'all'):
|
||||||
|
all_links.extend(acl.links.all())
|
||||||
|
|
||||||
# Get recently accessed links (last 30 days) - exclude None values
|
total_links = len(all_links)
|
||||||
thirty_days_ago = timezone.now() - timedelta(days=30)
|
|
||||||
active_links = ACLLink.objects.filter(
|
# Count active links from prefetched data
|
||||||
acl__server=obj,
|
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||||
last_access_time__isnull=False,
|
active_links = sum(1 for link in all_links
|
||||||
last_access_time__gte=thirty_days_ago
|
if link.last_access_time and link.last_access_time >= thirty_days_ago)
|
||||||
).count()
|
else:
|
||||||
|
# Fallback to direct queries (less efficient)
|
||||||
|
total_links = ACLLink.objects.filter(acl__server=obj).count()
|
||||||
|
thirty_days_ago = timezone.now() - timedelta(days=30)
|
||||||
|
active_links = ACLLink.objects.filter(
|
||||||
|
acl__server=obj,
|
||||||
|
last_access_time__isnull=False,
|
||||||
|
last_access_time__gte=thirty_days_ago
|
||||||
|
).count()
|
||||||
|
|
||||||
# Color coding based on activity
|
# Color coding based on activity
|
||||||
if user_count == 0:
|
if user_count == 0:
|
||||||
color = '#9ca3af' # gray - no users
|
color = '#9ca3af' # gray - no users
|
||||||
elif total_links == 0:
|
elif total_links == 0:
|
||||||
color = '#dc2626' # red - no links
|
color = '#dc2626' # red - no links
|
||||||
elif active_links > total_links * 0.7: # High activity
|
elif total_links > 0 and active_links > total_links * 0.7: # High activity
|
||||||
color = '#16a34a' # green
|
color = '#16a34a' # green
|
||||||
elif active_links > total_links * 0.3: # Medium activity
|
elif total_links > 0 and active_links > total_links * 0.3: # Medium activity
|
||||||
color = '#eab308' # yellow
|
color = '#eab308' # yellow
|
||||||
else:
|
else:
|
||||||
color = '#f97316' # orange - low activity
|
color = '#f97316' # orange - low activity
|
||||||
@@ -660,55 +677,14 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
|||||||
|
|
||||||
@admin.display(description='Activity')
|
@admin.display(description='Activity')
|
||||||
def activity_summary(self, obj):
|
def activity_summary(self, obj):
|
||||||
"""Display recent activity summary"""
|
"""Display recent activity summary (optimized)"""
|
||||||
try:
|
try:
|
||||||
from django.utils import timezone
|
# Simplified version - avoid heavy DB queries on list page
|
||||||
from datetime import timedelta
|
# This could be computed once per page load if needed
|
||||||
|
|
||||||
# Get recent access logs for this server
|
|
||||||
seven_days_ago = timezone.now() - timedelta(days=7)
|
|
||||||
recent_logs = AccessLog.objects.filter(
|
|
||||||
server=obj.name,
|
|
||||||
timestamp__gte=seven_days_ago
|
|
||||||
)
|
|
||||||
|
|
||||||
total_access = recent_logs.count()
|
|
||||||
success_access = recent_logs.filter(action='Success').count()
|
|
||||||
|
|
||||||
# Get latest access
|
|
||||||
latest_log = AccessLog.objects.filter(server=obj.name).order_by('-timestamp').first()
|
|
||||||
|
|
||||||
if latest_log:
|
|
||||||
local_time = localtime(latest_log.timestamp)
|
|
||||||
latest_str = local_time.strftime('%m-%d %H:%M')
|
|
||||||
|
|
||||||
# Time since last access
|
|
||||||
time_diff = timezone.now() - latest_log.timestamp
|
|
||||||
if time_diff.days > 0:
|
|
||||||
time_ago = f'{time_diff.days}d ago'
|
|
||||||
elif time_diff.seconds > 3600:
|
|
||||||
time_ago = f'{time_diff.seconds // 3600}h ago'
|
|
||||||
else:
|
|
||||||
time_ago = 'Recent'
|
|
||||||
else:
|
|
||||||
latest_str = 'Never'
|
|
||||||
time_ago = ''
|
|
||||||
|
|
||||||
# Color coding
|
|
||||||
if total_access == 0:
|
|
||||||
color = '#dc2626' # red - no activity
|
|
||||||
elif total_access > 50:
|
|
||||||
color = '#16a34a' # green - high activity
|
|
||||||
elif total_access > 10:
|
|
||||||
color = '#eab308' # yellow - medium activity
|
|
||||||
else:
|
|
||||||
color = '#f97316' # orange - low activity
|
|
||||||
|
|
||||||
return mark_safe(
|
return mark_safe(
|
||||||
f'<div style="font-size: 11px;">' +
|
f'<div style="font-size: 11px; color: #6b7280;">' +
|
||||||
f'<div style="color: {color}; font-weight: bold;">📊 {total_access} uses (7d)</div>' +
|
f'<div>📊 Activity data</div>' +
|
||||||
f'<div style="color: #6b7280;">✅ {success_access} success</div>' +
|
f'<div><small>Click to view details</small></div>' +
|
||||||
f'<div style="color: #6b7280;">🕒 {latest_str} {time_ago}</div>' +
|
|
||||||
f'</div>'
|
f'</div>'
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -716,42 +692,26 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
|||||||
|
|
||||||
@admin.display(description='Status')
|
@admin.display(description='Status')
|
||||||
def server_status_compact(self, obj):
|
def server_status_compact(self, obj):
|
||||||
"""Display server status in compact format"""
|
"""Display server status in compact format (optimized)"""
|
||||||
try:
|
try:
|
||||||
status = obj.get_server_status()
|
# Avoid expensive server connectivity checks on list page
|
||||||
if 'error' in status:
|
# Show basic info and let users click to check status
|
||||||
return mark_safe(
|
server_type_icons = {
|
||||||
f'<div style="color: #dc2626; font-size: 11px; font-weight: bold;">' +
|
'outline': '🔵',
|
||||||
f'❌ Error<br>' +
|
'wireguard': '🟢',
|
||||||
f'<span style="font-weight: normal;" title="{status["error"]}">' +
|
}
|
||||||
f'{status["error"][:30]}...</span>' +
|
icon = server_type_icons.get(obj.server_type, '⚪')
|
||||||
f'</div>'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract key metrics
|
|
||||||
status_items = []
|
|
||||||
if 'name' in status:
|
|
||||||
status_items.append(f"📛 {status['name'][:15]}")
|
|
||||||
if 'version' in status:
|
|
||||||
status_items.append(f"🔄 {status['version']}")
|
|
||||||
if 'keys' in status:
|
|
||||||
status_items.append(f"🔑 {status['keys']} keys")
|
|
||||||
if 'accessUrl' in status:
|
|
||||||
status_items.append("🌐 Available")
|
|
||||||
|
|
||||||
status_display = '<br>'.join(status_items) if status_items else 'Unknown'
|
|
||||||
|
|
||||||
return mark_safe(
|
return mark_safe(
|
||||||
f'<div style="color: #16a34a; font-size: 11px;">' +
|
f'<div style="color: #6b7280; font-size: 11px;">' +
|
||||||
f'✅ Online<br>' +
|
f'{icon} {obj.server_type.title()}<br>' +
|
||||||
f'<span style="color: #6b7280;">{status_display}</span>' +
|
f'<small>Click to check status</small>' +
|
||||||
f'</div>'
|
f'</div>'
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Don't let server connectivity issues break the admin interface
|
|
||||||
return mark_safe(
|
return mark_safe(
|
||||||
f'<div style="color: #f97316; font-size: 11px; font-weight: bold;">' +
|
f'<div style="color: #f97316; font-size: 11px; font-weight: bold;">' +
|
||||||
f'⚠️ Unavailable<br>' +
|
f'⚠️ Error<br>' +
|
||||||
f'<span style="font-weight: normal;" title="{str(e)}">' +
|
f'<span style="font-weight: normal;" title="{str(e)}">' +
|
||||||
f'{str(e)[:25]}...</span>' +
|
f'{str(e)[:25]}...</span>' +
|
||||||
f'</div>'
|
f'</div>'
|
||||||
@@ -760,6 +720,10 @@ class ServerAdmin(PolymorphicParentModelAdmin):
|
|||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
qs = qs.annotate(user_count=Count('acl'))
|
qs = qs.annotate(user_count=Count('acl'))
|
||||||
|
qs = qs.prefetch_related(
|
||||||
|
'acl_set__links',
|
||||||
|
'acl_set__user'
|
||||||
|
)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
#admin.site.register(User, UserAdmin)
|
#admin.site.register(User, UserAdmin)
|
||||||
@@ -768,7 +732,12 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
form = UserForm
|
form = UserForm
|
||||||
list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count')
|
list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count')
|
||||||
search_fields = ('username', 'hash')
|
search_fields = ('username', 'hash')
|
||||||
readonly_fields = ('hash_link', 'user_statistics_summary', 'recent_activity_display')
|
readonly_fields = ('hash_link', 'user_statistics_summary')
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
'all': ('admin/css/vpn_server_admin.css',)
|
||||||
|
}
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('User Information', {
|
('User Information', {
|
||||||
@@ -777,9 +746,9 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
('Access Information', {
|
('Access Information', {
|
||||||
'fields': ('hash_link', 'is_active')
|
'fields': ('hash_link', 'is_active')
|
||||||
}),
|
}),
|
||||||
('Statistics & Activity', {
|
('Statistics & Server Management', {
|
||||||
'fields': ('user_statistics_summary', 'recent_activity_display'),
|
'fields': ('user_statistics_summary',),
|
||||||
'classes': ('collapse',)
|
'classes': ('wide',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -845,23 +814,10 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
server = acl.server
|
server = acl.server
|
||||||
links = list(acl.links.all())
|
links = list(acl.links.all())
|
||||||
|
|
||||||
# Server status check
|
# Server header (no slow server status checks)
|
||||||
try:
|
|
||||||
server_status = server.get_server_status()
|
|
||||||
server_accessible = True
|
|
||||||
server_error = None
|
|
||||||
except Exception as e:
|
|
||||||
server_status = {}
|
|
||||||
server_accessible = False
|
|
||||||
server_error = str(e)
|
|
||||||
|
|
||||||
html += '<div class="server-section">'
|
|
||||||
|
|
||||||
# Server header
|
|
||||||
status_icon = '✅' if server_accessible else '❌'
|
|
||||||
type_icon = '🔵' if server.server_type == 'outline' else '🟢' if server.server_type == 'wireguard' else ''
|
type_icon = '🔵' if server.server_type == 'outline' else '🟢' if server.server_type == 'wireguard' else ''
|
||||||
html += f'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">'
|
html += f'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">'
|
||||||
html += f'<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600;">{type_icon} {server.name} {status_icon}</h5>'
|
html += f'<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600;">{type_icon} {server.name}</h5>'
|
||||||
|
|
||||||
# Server stats
|
# Server stats
|
||||||
server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None)
|
server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None)
|
||||||
@@ -871,11 +827,7 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
html += f'</span>'
|
html += f'</span>'
|
||||||
html += f'</div>'
|
html += f'</div>'
|
||||||
|
|
||||||
# Server error display
|
html += '<div class="server-section">'
|
||||||
if server_error:
|
|
||||||
html += f'<div style="background: #f8d7da; color: #721c24; padding: 8px; border-radius: 4px; margin-bottom: 8px; font-size: 12px;">'
|
|
||||||
html += f'⚠️ Error: {server_error[:80]}...'
|
|
||||||
html += f'</div>'
|
|
||||||
|
|
||||||
# Links display
|
# Links display
|
||||||
if links:
|
if links:
|
||||||
@@ -1136,7 +1088,7 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||||
|
|
||||||
def change_view(self, request, object_id, form_url='', extra_context=None):
|
def change_view(self, request, object_id, form_url='', extra_context=None):
|
||||||
"""Override change view to add user management data to context"""
|
"""Override change view to add user management data and fix layout"""
|
||||||
extra_context = extra_context or {}
|
extra_context = extra_context or {}
|
||||||
|
|
||||||
if object_id:
|
if object_id:
|
||||||
@@ -1235,13 +1187,39 @@ class ACLAdmin(admin.ModelAdmin):
|
|||||||
server = obj.server
|
server = obj.server
|
||||||
user = obj.user
|
user = obj.user
|
||||||
try:
|
try:
|
||||||
data = server.get_user(user)
|
# Use cached statistics instead of direct server requests
|
||||||
return format_object(data)
|
from .models import UserStatistics
|
||||||
|
user_stats = UserStatistics.objects.filter(
|
||||||
|
user=user,
|
||||||
|
server_name=server.name
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if user_stats:
|
||||||
|
# Format cached data nicely
|
||||||
|
data = {
|
||||||
|
'user': user.username,
|
||||||
|
'server': server.name,
|
||||||
|
'total_connections': user_stats.total_connections,
|
||||||
|
'recent_connections': user_stats.recent_connections,
|
||||||
|
'max_daily': user_stats.max_daily,
|
||||||
|
'last_updated': user_stats.updated_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'status': 'from_cache'
|
||||||
|
}
|
||||||
|
return format_object(data)
|
||||||
|
else:
|
||||||
|
# Fallback to minimal server check (avoid slow API calls on admin pages)
|
||||||
|
return mark_safe(
|
||||||
|
'<div style="background: #fff3cd; border: 1px solid #ffeaa7; padding: 8px; border-radius: 4px;">' +
|
||||||
|
'<strong>ℹ️ User Statistics:</strong><br>' +
|
||||||
|
'No cached statistics available.<br>' +
|
||||||
|
'<small>Run "Update user statistics cache" action to populate data.</small>' +
|
||||||
|
'</div>'
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.error(f"Failed to get user info for {user.username} on {server.name}: {e}")
|
logger.error(f"Failed to get cached user info for {user.username} on {server.name}: {e}")
|
||||||
return mark_safe(f"<span style='color: red;'>Server connection error: {e}</span>")
|
return mark_safe(f"<span style='color: red;'>Cache error: {e}</span>")
|
||||||
|
|
||||||
@admin.display(description='User Links')
|
@admin.display(description='User Links')
|
||||||
def display_links(self, obj):
|
def display_links(self, obj):
|
||||||
|
@@ -580,6 +580,9 @@ class OutlineServerAdmin(PolymorphicChildModelAdmin):
|
|||||||
html += f'<div><strong>Total Links:</strong> {total_links}</div>'
|
html += f'<div><strong>Total Links:</strong> {total_links}</div>'
|
||||||
html += f'<div><strong>Server Keys:</strong> {server_keys_count}</div>'
|
html += f'<div><strong>Server Keys:</strong> {server_keys_count}</div>'
|
||||||
html += '</div>'
|
html += '</div>'
|
||||||
|
html += '<div style="margin-top: 8px; font-size: 11px; color: #6c757d;">'
|
||||||
|
html += '📊 User activity data is from cached statistics for fast loading. Status indicators show usage patterns.'
|
||||||
|
html += '</div>'
|
||||||
html += '</div>'
|
html += '</div>'
|
||||||
|
|
||||||
# Get users data with ACL information
|
# Get users data with ACL information
|
||||||
@@ -599,17 +602,28 @@ class OutlineServerAdmin(PolymorphicChildModelAdmin):
|
|||||||
if last_access is None or link.last_access_time > last_access:
|
if last_access is None or link.last_access_time > last_access:
|
||||||
last_access = link.last_access_time
|
last_access = link.last_access_time
|
||||||
|
|
||||||
# Check if user has key on server
|
# Use cached statistics instead of live server check for performance
|
||||||
server_key = False
|
user_stats = UserStatistics.objects.filter(user=user, server_name=obj.name)
|
||||||
try:
|
server_key_status = "unknown"
|
||||||
obj.get_user(user)
|
total_user_connections = 0
|
||||||
server_key = True
|
recent_user_connections = 0
|
||||||
except Exception:
|
|
||||||
pass
|
if user_stats.exists():
|
||||||
|
# User has cached data, likely has server access
|
||||||
|
total_user_connections = sum(stat.total_connections for stat in user_stats)
|
||||||
|
recent_user_connections = sum(stat.recent_connections for stat in user_stats)
|
||||||
|
|
||||||
|
if total_user_connections > 0:
|
||||||
|
server_key_status = "cached_active"
|
||||||
|
else:
|
||||||
|
server_key_status = "cached_inactive"
|
||||||
|
else:
|
||||||
|
# No cached data - either new user or no access
|
||||||
|
server_key_status = "no_cache"
|
||||||
|
|
||||||
html += '<div style="background: #ffffff; border: 1px solid #e9ecef; border-radius: 0.25rem; padding: 0.75rem; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center;">'
|
html += '<div style="background: #ffffff; border: 1px solid #e9ecef; border-radius: 0.25rem; padding: 0.75rem; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center;">'
|
||||||
|
|
||||||
# User info
|
# User info section
|
||||||
html += '<div style="flex: 1;">'
|
html += '<div style="flex: 1;">'
|
||||||
html += f'<div style="font-weight: 500; font-size: 14px; color: #495057;">{user.username}'
|
html += f'<div style="font-weight: 500; font-size: 14px; color: #495057;">{user.username}'
|
||||||
if user.comment:
|
if user.comment:
|
||||||
@@ -622,19 +636,30 @@ class OutlineServerAdmin(PolymorphicChildModelAdmin):
|
|||||||
html += f' | Last access: {local_time.strftime("%Y-%m-%d %H:%M")}'
|
html += f' | Last access: {local_time.strftime("%Y-%m-%d %H:%M")}'
|
||||||
else:
|
else:
|
||||||
html += ' | Never accessed'
|
html += ' | Never accessed'
|
||||||
html += '</div>'
|
|
||||||
html += '</div>'
|
|
||||||
|
|
||||||
# Status and actions
|
# Add usage statistics inside the same div
|
||||||
|
if total_user_connections > 0:
|
||||||
|
html += f' | {total_user_connections} total uses'
|
||||||
|
if recent_user_connections > 0:
|
||||||
|
html += f' ({recent_user_connections} recent)'
|
||||||
|
|
||||||
|
html += '</div>' # End user info
|
||||||
|
html += '</div>' # End flex-1 div
|
||||||
|
|
||||||
|
# Status and actions section
|
||||||
html += '<div style="display: flex; gap: 8px; align-items: center;">'
|
html += '<div style="display: flex; gap: 8px; align-items: center;">'
|
||||||
if server_key:
|
|
||||||
html += '<span style="background: #d4edda; color: #155724; padding: 2px 6px; border-radius: 3px; font-size: 10px;">✅ On Server</span>'
|
# Status indicator based on cached data
|
||||||
|
if server_key_status == "cached_active":
|
||||||
|
html += '<span style="background: #d4edda; color: #155724; padding: 2px 6px; border-radius: 3px; font-size: 10px;">📊 Active User</span>'
|
||||||
|
elif server_key_status == "cached_inactive":
|
||||||
|
html += '<span style="background: #fff3cd; color: #856404; padding: 2px 6px; border-radius: 3px; font-size: 10px;">📊 Inactive</span>'
|
||||||
else:
|
else:
|
||||||
html += '<span style="background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 10px;">❌ Missing</span>'
|
html += '<span style="background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 10px;">❓ No Data</span>'
|
||||||
|
|
||||||
html += f'<a href="/admin/vpn/user/{user.id}/change/" class="btn btn-sm btn-outline-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; border-radius: 0.2rem; margin: 0 0.1rem;">👤 Edit</a>'
|
html += f'<a href="/admin/vpn/user/{user.id}/change/" class="btn btn-sm btn-outline-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem; border-radius: 0.2rem; margin: 0 0.1rem;">👤 Edit</a>'
|
||||||
html += '</div>'
|
html += '</div>' # End actions div
|
||||||
html += '</div>'
|
html += '</div>' # End main user div
|
||||||
else:
|
else:
|
||||||
html += '<div style="color: #6c757d; font-style: italic; text-align: center; padding: 20px; background: #f9fafb; border-radius: 6px;">'
|
html += '<div style="color: #6c757d; font-style: italic; text-align: center; padding: 20px; background: #f9fafb; border-radius: 6px;">'
|
||||||
html += 'No users assigned to this server'
|
html += 'No users assigned to this server'
|
||||||
|
Reference in New Issue
Block a user