mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 06:27:15 +00:00
management command for cleanup old access logs
This commit is contained in:
BIN
celerybeat-schedule-shm
Normal file
BIN
celerybeat-schedule-shm
Normal file
Binary file not shown.
BIN
celerybeat-schedule-wal
Normal file
BIN
celerybeat-schedule-wal
Normal file
Binary file not shown.
15
cleanup_analysis.sql
Normal file
15
cleanup_analysis.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Проверить количество записей без acl_link_id
|
||||
SELECT COUNT(*) as total_without_link
|
||||
FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = '';
|
||||
|
||||
-- Проверить общее количество записей
|
||||
SELECT COUNT(*) as total_records FROM vpn_accesslog;
|
||||
|
||||
-- Показать распределение по датам (последние записи без ссылок)
|
||||
SELECT DATE(timestamp) as date, COUNT(*) as count
|
||||
FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = ''
|
||||
GROUP BY DATE(timestamp)
|
||||
ORDER BY date DESC
|
||||
LIMIT 10;
|
35
cleanup_options.sql
Normal file
35
cleanup_options.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- ВАРИАНТ 1: Удалить ВСЕ записи без acl_link_id
|
||||
-- ОСТОРОЖНО! Это удалит все старые логи
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = '';
|
||||
|
||||
-- ВАРИАНТ 2: Удалить записи без acl_link_id старше 30 дней
|
||||
-- Более безопасный вариант
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < NOW() - INTERVAL 30 DAY;
|
||||
|
||||
-- ВАРИАНТ 3: Удалить записи без acl_link_id старше 7 дней
|
||||
-- Еще более консервативный подход
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < NOW() - INTERVAL 7 DAY;
|
||||
|
||||
-- ВАРИАНТ 4: Оставить только последние 1000 записей без ссылок (для истории)
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1000
|
||||
) AS recent_logs
|
||||
);
|
||||
|
||||
-- ВАРИАНТ 5: Поэтапное удаление (для больших БД)
|
||||
-- Удаляем по 10000 записей за раз
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < NOW() - INTERVAL 30 DAY
|
||||
LIMIT 10000;
|
277
vpn/admin.py
277
vpn/admin.py
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import shortuuid
|
||||
from polymorphic.admin import (
|
||||
PolymorphicParentModelAdmin,
|
||||
)
|
||||
@@ -578,7 +579,20 @@ class UserAdmin(admin.ModelAdmin):
|
||||
form = UserForm
|
||||
list_display = ('username', 'comment', 'registration_date', 'hash_link', 'server_count')
|
||||
search_fields = ('username', 'hash')
|
||||
readonly_fields = ('hash_link',)
|
||||
readonly_fields = ('hash_link', 'user_statistics_summary', 'recent_activity_display')
|
||||
|
||||
fieldsets = (
|
||||
('User Information', {
|
||||
'fields': ('username', 'first_name', 'last_name', 'email', 'comment')
|
||||
}),
|
||||
('Access Information', {
|
||||
'fields': ('hash_link', 'is_active')
|
||||
}),
|
||||
('Statistics & Activity', {
|
||||
'fields': ('user_statistics_summary', 'recent_activity_display'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.display(description='User Portal', ordering='hash')
|
||||
def hash_link(self, obj):
|
||||
@@ -591,15 +605,274 @@ class UserAdmin(admin.ModelAdmin):
|
||||
'</div>',
|
||||
portal_url, json_url
|
||||
)
|
||||
|
||||
@admin.display(description='User Statistics Summary')
|
||||
def user_statistics_summary(self, obj):
|
||||
try:
|
||||
from .models import UserStatistics
|
||||
from django.db import models
|
||||
|
||||
# Get statistics for this user
|
||||
user_stats = UserStatistics.objects.filter(user=obj).aggregate(
|
||||
total_connections=models.Sum('total_connections'),
|
||||
recent_connections=models.Sum('recent_connections'),
|
||||
total_links=models.Count('id'),
|
||||
max_daily_peak=models.Max('max_daily')
|
||||
)
|
||||
|
||||
# Get server breakdown
|
||||
server_breakdown = UserStatistics.objects.filter(user=obj).values('server_name').annotate(
|
||||
connections=models.Sum('total_connections'),
|
||||
links=models.Count('id')
|
||||
).order_by('-connections')
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 12px; border-radius: 6px; margin: 8px 0;">'
|
||||
html += f'<div style="display: flex; gap: 20px; margin-bottom: 12px; flex-wrap: wrap;">'
|
||||
html += f'<div><strong>Total Uses:</strong> {user_stats["total_connections"] or 0}</div>'
|
||||
html += f'<div><strong>Recent (30d):</strong> {user_stats["recent_connections"] or 0}</div>'
|
||||
html += f'<div><strong>Total Links:</strong> {user_stats["total_links"] or 0}</div>'
|
||||
if user_stats["max_daily_peak"]:
|
||||
html += f'<div><strong>Daily Peak:</strong> {user_stats["max_daily_peak"]}</div>'
|
||||
html += f'</div>'
|
||||
|
||||
if server_breakdown:
|
||||
html += '<div><strong>By Server:</strong></div>'
|
||||
html += '<ul style="margin: 8px 0; padding-left: 20px;">'
|
||||
for server in server_breakdown:
|
||||
html += f'<li>{server["server_name"]}: {server["connections"]} uses ({server["links"]} links)</li>'
|
||||
html += '</ul>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
except Exception as e:
|
||||
return mark_safe(f'<span style="color: #dc2626;">Error loading statistics: {e}</span>')
|
||||
|
||||
@admin.display(description='Recent Activity')
|
||||
def recent_activity_display(self, obj):
|
||||
try:
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
# Get recent access logs for this user
|
||||
recent_logs = AccessLog.objects.filter(
|
||||
user=obj.username,
|
||||
timestamp__gte=timezone.now() - timedelta(days=7)
|
||||
).order_by('-timestamp')[:10]
|
||||
|
||||
if not recent_logs:
|
||||
return mark_safe('<div style="color: #6b7280; font-style: italic;">No recent activity</div>')
|
||||
|
||||
html = '<div style="background: #f8f9fa; padding: 12px; border-radius: 6px; margin: 8px 0;">'
|
||||
html += '<div style="font-weight: bold; margin-bottom: 8px;">Last 7 days:</div>'
|
||||
|
||||
for log in recent_logs:
|
||||
local_time = localtime(log.timestamp)
|
||||
time_str = local_time.strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
# Status color coding
|
||||
if log.action == 'Success':
|
||||
color = '#16a34a'
|
||||
icon = '✅'
|
||||
elif log.action == 'Failed':
|
||||
color = '#dc2626'
|
||||
icon = '❌'
|
||||
else:
|
||||
color = '#6b7280'
|
||||
icon = 'ℹ️'
|
||||
|
||||
link_display = log.acl_link_id[:12] + '...' if log.acl_link_id and len(log.acl_link_id) > 12 else log.acl_link_id or 'N/A'
|
||||
|
||||
html += f'<div style="display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid #e5e7eb; font-size: 12px;">'
|
||||
html += f'<span><span style="color: {color};">{icon}</span> {log.server} / {link_display}</span>'
|
||||
html += f'<span style="color: #6b7280;">{time_str}</span>'
|
||||
html += f'</div>'
|
||||
|
||||
html += '</div>'
|
||||
return mark_safe(html)
|
||||
except Exception as e:
|
||||
return mark_safe(f'<span style="color: #dc2626;">Error loading activity: {e}</span>')
|
||||
|
||||
@admin.display(description='Allowed servers', ordering='server_count')
|
||||
def server_count(self, obj):
|
||||
return obj.server_count
|
||||
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
qs = qs.annotate(server_count=Count('acl'))
|
||||
return qs
|
||||
|
||||
def get_urls(self):
|
||||
"""Add custom URLs for link management"""
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('<int:user_id>/add-link/', self.admin_site.admin_view(self.add_link_view), name='user_add_link'),
|
||||
path('<int:user_id>/delete-link/<int:link_id>/', self.admin_site.admin_view(self.delete_link_view), name='user_delete_link'),
|
||||
path('<int:user_id>/add-server-access/', self.admin_site.admin_view(self.add_server_access_view), name='user_add_server_access'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def add_link_view(self, request, user_id):
|
||||
"""AJAX view to add a new link for user on specific server"""
|
||||
from django.http import JsonResponse
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
server_id = request.POST.get('server_id')
|
||||
comment = request.POST.get('comment', '')
|
||||
|
||||
if not server_id:
|
||||
return JsonResponse({'error': 'Server ID is required'}, status=400)
|
||||
|
||||
server = Server.objects.get(pk=server_id)
|
||||
acl = ACL.objects.get(user=user, server=server)
|
||||
|
||||
# Create new link
|
||||
new_link = ACLLink.objects.create(
|
||||
acl=acl,
|
||||
comment=comment,
|
||||
link=shortuuid.ShortUUID().random(length=16)
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'link_id': new_link.id,
|
||||
'link': new_link.link,
|
||||
'comment': new_link.comment,
|
||||
'url': f"{EXTERNAL_ADDRESS}/ss/{new_link.link}#{server.name}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||
|
||||
def delete_link_view(self, request, user_id, link_id):
|
||||
"""AJAX view to delete a specific link"""
|
||||
from django.http import JsonResponse
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
link = ACLLink.objects.get(pk=link_id, acl__user=user)
|
||||
link.delete()
|
||||
|
||||
return JsonResponse({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||
|
||||
def add_server_access_view(self, request, user_id):
|
||||
"""AJAX view to add server access for user"""
|
||||
from django.http import JsonResponse
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
server_id = request.POST.get('server_id')
|
||||
|
||||
if not server_id:
|
||||
return JsonResponse({'error': 'Server ID is required'}, status=400)
|
||||
|
||||
server = Server.objects.get(pk=server_id)
|
||||
|
||||
# Check if ACL already exists
|
||||
if ACL.objects.filter(user=user, server=server).exists():
|
||||
return JsonResponse({'error': 'User already has access to this server'}, status=400)
|
||||
|
||||
# Create new ACL (with default link)
|
||||
acl = ACL.objects.create(user=user, server=server)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'server_name': server.name,
|
||||
'server_type': server.server_type,
|
||||
'acl_id': acl.id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
return JsonResponse({'error': 'Invalid request method'}, status=405)
|
||||
|
||||
def change_view(self, request, object_id, form_url='', extra_context=None):
|
||||
"""Override change view to add extensive user management data"""
|
||||
extra_context = extra_context or {}
|
||||
|
||||
if object_id:
|
||||
try:
|
||||
user = User.objects.get(pk=object_id)
|
||||
|
||||
# Get all ACLs and links for this user
|
||||
user_acls = ACL.objects.filter(user=user).select_related('server').prefetch_related('links')
|
||||
|
||||
# Get all available servers
|
||||
all_servers = Server.objects.all()
|
||||
|
||||
# Get user statistics
|
||||
try:
|
||||
from .models import UserStatistics
|
||||
user_stats = UserStatistics.objects.filter(user=user).select_related('user')
|
||||
except:
|
||||
user_stats = []
|
||||
|
||||
# Get recent access logs
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
recent_logs = AccessLog.objects.filter(
|
||||
user=user.username,
|
||||
timestamp__gte=timezone.now() - timedelta(days=30)
|
||||
).order_by('-timestamp')[:50]
|
||||
|
||||
# Organize data by server
|
||||
servers_data = {}
|
||||
for acl in user_acls:
|
||||
server = acl.server
|
||||
|
||||
# Get server status
|
||||
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)
|
||||
|
||||
# Get links for this ACL
|
||||
links = list(acl.links.all())
|
||||
|
||||
# Get statistics for this server
|
||||
server_stats = [s for s in user_stats if s.server_name == server.name]
|
||||
|
||||
servers_data[server.name] = {
|
||||
'server': server,
|
||||
'acl': acl,
|
||||
'links': links,
|
||||
'statistics': server_stats,
|
||||
'status': server_status,
|
||||
'accessible': server_accessible,
|
||||
'error': server_error,
|
||||
}
|
||||
|
||||
# Get available servers not yet assigned
|
||||
assigned_server_ids = [acl.server.id for acl in user_acls]
|
||||
unassigned_servers = all_servers.exclude(id__in=assigned_server_ids)
|
||||
|
||||
extra_context.update({
|
||||
'user_object': user,
|
||||
'servers_data': servers_data,
|
||||
'unassigned_servers': unassigned_servers,
|
||||
'recent_logs': recent_logs,
|
||||
'external_address': EXTERNAL_ADDRESS,
|
||||
})
|
||||
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
import logging
|
||||
|
158
vpn/management/commands/cleanup_access_logs.py
Normal file
158
vpn/management/commands/cleanup_access_logs.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection, transaction
|
||||
from datetime import datetime, timedelta
|
||||
from vpn.models import AccessLog
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Clean up old AccessLog entries without acl_link_id'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--days',
|
||||
type=int,
|
||||
default=30,
|
||||
help='Delete logs older than this many days (default: 30)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--batch-size',
|
||||
type=int,
|
||||
default=10000,
|
||||
help='Number of records to delete in each batch (default: 10000)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be deleted without actually deleting'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--keep-recent',
|
||||
type=int,
|
||||
default=1000,
|
||||
help='Keep this many recent logs even if they have no link (default: 1000)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
days = options['days']
|
||||
batch_size = options['batch_size']
|
||||
dry_run = options['dry_run']
|
||||
keep_recent = options['keep_recent']
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
self.stdout.write(f"🔍 Analyzing AccessLog cleanup...")
|
||||
self.stdout.write(f" - Delete logs without acl_link_id older than {days} days")
|
||||
self.stdout.write(f" - Keep {keep_recent} most recent logs without links")
|
||||
self.stdout.write(f" - Batch size: {batch_size}")
|
||||
self.stdout.write(f" - Dry run: {dry_run}")
|
||||
|
||||
# Count total records to be deleted
|
||||
with connection.cursor() as cursor:
|
||||
# Count logs without acl_link_id
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
""")
|
||||
total_without_link = cursor.fetchone()[0]
|
||||
|
||||
# Count logs to be deleted (older than cutoff, excluding recent ones to keep)
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < %s
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT %s
|
||||
) AS recent_logs
|
||||
)
|
||||
""", [cutoff_date, keep_recent])
|
||||
total_to_delete = cursor.fetchone()[0]
|
||||
|
||||
# Count total records
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
total_records = cursor.fetchone()[0]
|
||||
|
||||
self.stdout.write(f"📊 Statistics:")
|
||||
self.stdout.write(f" - Total AccessLog records: {total_records:,}")
|
||||
self.stdout.write(f" - Records without acl_link_id: {total_without_link:,}")
|
||||
self.stdout.write(f" - Records to be deleted: {total_to_delete:,}")
|
||||
self.stdout.write(f" - Records to be kept (recent): {keep_recent:,}")
|
||||
|
||||
if total_to_delete == 0:
|
||||
self.stdout.write(self.style.SUCCESS("✅ No records to delete."))
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING(f"🔍 DRY RUN: Would delete {total_to_delete:,} records"))
|
||||
return
|
||||
|
||||
# Confirm deletion
|
||||
if not options.get('verbosity', 1) == 0: # Only ask if not --verbosity=0
|
||||
confirm = input(f"❓ Delete {total_to_delete:,} records? (yes/no): ")
|
||||
if confirm.lower() != 'yes':
|
||||
self.stdout.write("❌ Cancelled.")
|
||||
return
|
||||
|
||||
self.stdout.write(f"🗑️ Starting deletion of {total_to_delete:,} records...")
|
||||
|
||||
deleted_total = 0
|
||||
batch_num = 0
|
||||
|
||||
while True:
|
||||
batch_num += 1
|
||||
|
||||
with transaction.atomic():
|
||||
with connection.cursor() as cursor:
|
||||
# Delete batch
|
||||
cursor.execute("""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE (acl_link_id IS NULL OR acl_link_id = '')
|
||||
AND timestamp < %s
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT %s
|
||||
) AS recent_logs
|
||||
)
|
||||
LIMIT %s
|
||||
""", [cutoff_date, keep_recent, batch_size])
|
||||
|
||||
deleted_in_batch = cursor.rowcount
|
||||
|
||||
if deleted_in_batch == 0:
|
||||
break
|
||||
|
||||
deleted_total += deleted_in_batch
|
||||
progress = (deleted_total / total_to_delete) * 100
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num}: Deleted {deleted_in_batch:,} records "
|
||||
f"(Total: {deleted_total:,}/{total_to_delete:,}, {progress:.1f}%)"
|
||||
)
|
||||
|
||||
if deleted_in_batch < batch_size:
|
||||
break
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"✅ Cleanup completed!"))
|
||||
self.stdout.write(f" - Deleted {deleted_total:,} old AccessLog records")
|
||||
self.stdout.write(f" - Kept {keep_recent:,} recent records without links")
|
||||
|
||||
# Show final statistics
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
final_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE acl_link_id IS NULL OR acl_link_id = ''
|
||||
""")
|
||||
final_without_link = cursor.fetchone()[0]
|
||||
|
||||
self.stdout.write(f"📊 Final statistics:")
|
||||
self.stdout.write(f" - Total AccessLog records: {final_total:,}")
|
||||
self.stdout.write(f" - Records without acl_link_id: {final_without_link:,}")
|
208
vpn/management/commands/cleanup_logs.py
Normal file
208
vpn/management/commands/cleanup_logs.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''
|
||||
Clean up AccessLog entries efficiently using direct SQL.
|
||||
|
||||
Examples:
|
||||
# Delete logs without acl_link_id older than 30 days (recommended)
|
||||
python manage.py cleanup_logs --keep-days=30
|
||||
|
||||
# Keep only last 5000 logs without acl_link_id
|
||||
python manage.py cleanup_logs --keep-count=5000
|
||||
|
||||
# Delete ALL logs older than 7 days (including with acl_link_id)
|
||||
python manage.py cleanup_logs --keep-days=7 --target=all
|
||||
|
||||
# Preview what would be deleted
|
||||
python manage.py cleanup_logs --keep-days=30 --dry-run
|
||||
|
||||
# Force delete without confirmation
|
||||
python manage.py cleanup_logs --keep-days=30 --force
|
||||
'''
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# Primary options (mutually exclusive)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument(
|
||||
'--keep-days',
|
||||
type=int,
|
||||
help='Keep logs newer than this many days (delete older)'
|
||||
)
|
||||
group.add_argument(
|
||||
'--keep-count',
|
||||
type=int,
|
||||
help='Keep this many most recent logs (delete the rest)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--target',
|
||||
choices=['no-links', 'all'],
|
||||
default='no-links',
|
||||
help='Target: "no-links" = only logs without acl_link_id (default), "all" = all logs'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be deleted without actually deleting'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Skip confirmation prompt'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
keep_days = options.get('keep_days')
|
||||
keep_count = options.get('keep_count')
|
||||
target = options['target']
|
||||
dry_run = options['dry_run']
|
||||
force = options['force']
|
||||
|
||||
# Build SQL conditions
|
||||
if target == 'no-links':
|
||||
base_condition = "(acl_link_id IS NULL OR acl_link_id = '')"
|
||||
target_desc = "logs without acl_link_id"
|
||||
else:
|
||||
base_condition = "1=1"
|
||||
target_desc = "all logs"
|
||||
|
||||
# Get current statistics
|
||||
with connection.cursor() as cursor:
|
||||
# Total records
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
total_records = cursor.fetchone()[0]
|
||||
|
||||
# Target records count
|
||||
cursor.execute(f"SELECT COUNT(*) FROM vpn_accesslog WHERE {base_condition}")
|
||||
target_records = cursor.fetchone()[0]
|
||||
|
||||
# Records to delete
|
||||
if keep_days:
|
||||
cutoff_date = timezone.now() - timedelta(days=keep_days)
|
||||
cursor.execute(f"""
|
||||
SELECT COUNT(*) FROM vpn_accesslog
|
||||
WHERE {base_condition} AND timestamp < %s
|
||||
""", [cutoff_date])
|
||||
to_delete = cursor.fetchone()[0]
|
||||
strategy = f"older than {keep_days} days"
|
||||
else: # keep_count
|
||||
to_delete = max(0, target_records - keep_count)
|
||||
strategy = f"keeping only {keep_count} most recent"
|
||||
|
||||
# Print statistics
|
||||
self.stdout.write("🗑️ AccessLog Cleanup" + (" (DRY RUN)" if dry_run else ""))
|
||||
self.stdout.write(f" Target: {target_desc}")
|
||||
self.stdout.write(f" Strategy: {strategy}")
|
||||
self.stdout.write("")
|
||||
self.stdout.write("📊 Statistics:")
|
||||
self.stdout.write(f" Total AccessLog records: {total_records:,}")
|
||||
self.stdout.write(f" Target records: {target_records:,}")
|
||||
self.stdout.write(f" Records to delete: {to_delete:,}")
|
||||
self.stdout.write(f" Records to keep: {target_records - to_delete:,}")
|
||||
|
||||
if total_records > 0:
|
||||
delete_percent = (to_delete / total_records) * 100
|
||||
self.stdout.write(f" Deletion percentage: {delete_percent:.1f}%")
|
||||
|
||||
if to_delete == 0:
|
||||
self.stdout.write(self.style.SUCCESS("✅ No records to delete."))
|
||||
return
|
||||
|
||||
# Show SQL that will be executed
|
||||
if dry_run or not force:
|
||||
self.stdout.write("")
|
||||
self.stdout.write("📝 SQL to execute:")
|
||||
if keep_days:
|
||||
sql_preview = f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition} AND timestamp < '{cutoff_date.strftime('%Y-%m-%d %H:%M:%S')}'
|
||||
"""
|
||||
else: # keep_count
|
||||
sql_preview = f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT {keep_count}
|
||||
) AS recent_logs
|
||||
)
|
||||
"""
|
||||
self.stdout.write(sql_preview.strip())
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.WARNING(f"🔍 DRY RUN: Would delete {to_delete:,} records"))
|
||||
return
|
||||
|
||||
# Confirm deletion
|
||||
if not force:
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.ERROR(f"⚠️ About to DELETE {to_delete:,} records!"))
|
||||
confirm = input("Type 'DELETE' to confirm: ")
|
||||
if confirm != 'DELETE':
|
||||
self.stdout.write("❌ Cancelled.")
|
||||
return
|
||||
|
||||
# Execute deletion
|
||||
self.stdout.write("")
|
||||
self.stdout.write(f"🗑️ Deleting {to_delete:,} records...")
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
if keep_days:
|
||||
# Simple time-based deletion
|
||||
cursor.execute(f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition} AND timestamp < %s
|
||||
""", [cutoff_date])
|
||||
else:
|
||||
# Keep count deletion (more complex)
|
||||
cursor.execute(f"""
|
||||
DELETE FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM vpn_accesslog
|
||||
WHERE {base_condition}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT %s
|
||||
) AS recent_logs
|
||||
)
|
||||
""", [keep_count])
|
||||
|
||||
deleted_count = cursor.rowcount
|
||||
|
||||
# Final statistics
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) FROM vpn_accesslog")
|
||||
final_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute(f"SELECT COUNT(*) FROM vpn_accesslog WHERE {base_condition}")
|
||||
final_target = cursor.fetchone()[0]
|
||||
|
||||
self.stdout.write("")
|
||||
self.stdout.write(self.style.SUCCESS("✅ Cleanup completed!"))
|
||||
self.stdout.write(f" Deleted: {deleted_count:,} records")
|
||||
self.stdout.write(f" Remaining total: {final_total:,}")
|
||||
|
||||
if target == 'no-links':
|
||||
self.stdout.write(f" Remaining without links: {final_target:,}")
|
||||
|
||||
# Calculate space saved (rough estimate)
|
||||
if deleted_count > 0:
|
||||
# Rough estimate: ~200 bytes per AccessLog record
|
||||
space_saved_mb = (deleted_count * 200) / (1024 * 1024)
|
||||
if space_saved_mb > 1024:
|
||||
space_saved_gb = space_saved_mb / 1024
|
||||
self.stdout.write(f" Estimated space saved: ~{space_saved_gb:.1f} GB")
|
||||
else:
|
||||
self.stdout.write(f" Estimated space saved: ~{space_saved_mb:.1f} MB")
|
51
vpn/management/commands/simple_cleanup_logs.py
Normal file
51
vpn/management/commands/simple_cleanup_logs.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from vpn.models import AccessLog
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Simple cleanup of AccessLog entries without acl_link_id using Django ORM'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--days',
|
||||
type=int,
|
||||
default=30,
|
||||
help='Delete logs older than this many days (default: 30)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
days = options['days']
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
# Count records to be deleted
|
||||
old_logs = AccessLog.objects.filter(
|
||||
acl_link_id__isnull=True,
|
||||
timestamp__lt=cutoff_date
|
||||
)
|
||||
|
||||
# Also include empty string acl_link_id
|
||||
empty_logs = AccessLog.objects.filter(
|
||||
acl_link_id='',
|
||||
timestamp__lt=cutoff_date
|
||||
)
|
||||
|
||||
total_old = old_logs.count()
|
||||
total_empty = empty_logs.count()
|
||||
total_to_delete = total_old + total_empty
|
||||
|
||||
self.stdout.write(f"Found {total_to_delete:,} old logs without acl_link_id to delete")
|
||||
|
||||
if total_to_delete == 0:
|
||||
self.stdout.write("Nothing to delete.")
|
||||
return
|
||||
|
||||
# Delete in batches to avoid memory issues
|
||||
self.stdout.write("Deleting old logs...")
|
||||
|
||||
deleted_count = 0
|
||||
deleted_count += old_logs._raw_delete(old_logs.db)
|
||||
deleted_count += empty_logs._raw_delete(empty_logs.db)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {deleted_count:,} old AccessLog records"))
|
412
vpn/templates/admin/vpn/user/change_form.html
Normal file
412
vpn/templates/admin/vpn/user/change_form.html
Normal file
@@ -0,0 +1,412 @@
|
||||
{% extends "admin/change_form.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block content_title %}
|
||||
<h1 class="h4 m-0 pr-3 mr-3 border-right">
|
||||
{% if original %}
|
||||
👤 User: {{ original.username }}
|
||||
{% else %}
|
||||
👤 Add User
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if original and servers_data %}
|
||||
<!-- User Access Management Panel -->
|
||||
<div class="user-access-management" style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin: 24px 0;">
|
||||
<h3 style="margin: 0 0 20px 0; color: #1f2937; font-size: 18px;">🔗 User Access Management</h3>
|
||||
|
||||
<!-- User Portal Links -->
|
||||
<div style="background: #eff6ff; border: 1px solid #dbeafe; border-radius: 6px; padding: 16px; margin-bottom: 20px;">
|
||||
<h4 style="margin: 0 0 12px 0; color: #1e40af; font-size: 14px;">📱 User Portal Access</h4>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<a href="{{ external_address }}/u/{{ original.hash }}" target="_blank"
|
||||
style="background: #4ade80; color: #000; padding: 8px 16px; border-radius: 6px; text-decoration: none; font-weight: bold;">
|
||||
🌐 Open User Portal
|
||||
</a>
|
||||
<a href="{{ external_address }}/stat/{{ original.hash }}" target="_blank"
|
||||
style="background: #3b82f6; color: #fff; padding: 8px 16px; border-radius: 6px; text-decoration: none; font-weight: bold;">
|
||||
📄 JSON API
|
||||
</a>
|
||||
<span style="background: #f3f4f6; padding: 8px 12px; border-radius: 6px; font-family: monospace; font-size: 12px; color: #6b7280;">
|
||||
Hash: {{ original.hash }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Servers & Links Management -->
|
||||
<div style="display: grid; gap: 20px;">
|
||||
{% for server_name, data in servers_data.items %}
|
||||
<div style="background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h4 style="margin: 0; color: #1f2937; font-size: 16px;">
|
||||
{% if data.server.server_type == 'outline' %}🔵{% elif data.server.server_type == 'wireguard' %}🟢{% else %}⚪{% endif %}
|
||||
{{ server_name }}
|
||||
</h4>
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
{% if data.accessible %}
|
||||
<span style="background: #dcfce7; color: #166534; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
|
||||
✅ Online
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="background: #fef2f2; color: #dc2626; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
|
||||
❌ Offline
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Server Statistics -->
|
||||
{% for stat in data.statistics %}
|
||||
{% if not stat.acl_link_id %}
|
||||
<span style="background: #f3f4f6; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
|
||||
📊 {{ stat.total_connections }} uses
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if data.error %}
|
||||
<div style="background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; padding: 12px; margin-bottom: 16px;">
|
||||
<div style="color: #dc2626; font-size: 14px;">⚠️ Server Error:</div>
|
||||
<div style="color: #7f1d1d; font-size: 12px; margin-top: 4px;">{{ data.error }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Server Status Info -->
|
||||
{% if data.status %}
|
||||
<div style="background: #f8fafc; border-radius: 6px; padding: 12px; margin-bottom: 16px;">
|
||||
<div style="font-weight: bold; color: #374151; margin-bottom: 8px;">Server Status:</div>
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px;">
|
||||
{% for key, value in data.status.items %}
|
||||
<span><strong>{{ key }}:</strong> {{ value }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Links for this server -->
|
||||
<div>
|
||||
<div style="font-weight: bold; color: #374151; margin-bottom: 12px;">
|
||||
Access Links ({{ data.links|length }}):
|
||||
</div>
|
||||
|
||||
{% if data.links %}
|
||||
<div style="display: grid; gap: 12px;">
|
||||
{% for link in data.links %}
|
||||
<div class="link-container" style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<div style="display: flex; gap: 12px; align-items: center; flex: 1;">
|
||||
<span style="font-family: monospace; font-size: 14px; color: #2563eb; font-weight: bold;">
|
||||
{{ link.link|slice:":16" }}{% if link.link|length > 16 %}...{% endif %}
|
||||
</span>
|
||||
<span style="color: #6b7280; font-size: 12px;">
|
||||
{{ link.comment|default:"No comment" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Link Statistics -->
|
||||
{% for stat in data.statistics %}
|
||||
{% if stat.acl_link_id == link.link %}
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<span style="background: #eff6ff; color: #1d4ed8; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
|
||||
✨ {{ stat.total_connections }} total
|
||||
</span>
|
||||
<span style="background: #f0fdf4; color: #166534; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
|
||||
📅 {{ stat.recent_connections }} recent
|
||||
</span>
|
||||
{% if stat.max_daily > 0 %}
|
||||
<span style="background: #fef3c7; color: #d97706; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
|
||||
📈 {{ stat.max_daily }} peak
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<a href="{{ external_address }}/ss/{{ link.link }}#{{ server_name }}" target="_blank"
|
||||
style="background: #3b82f6; color: white; padding: 4px 8px; border-radius: 4px; text-decoration: none; font-size: 11px;">
|
||||
🔗 Test Link
|
||||
</a>
|
||||
|
||||
<button type="button" class="delete-link-btn"
|
||||
data-link-id="{{ link.id }}" data-link-name="{{ link.link|slice:":16" }}"
|
||||
style="background: #dc2626; color: white; padding: 4px 8px; border-radius: 4px; border: none; font-size: 11px; cursor: pointer;">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
|
||||
{% if link.last_access_time %}
|
||||
<span style="background: #f3f4f6; padding: 4px 8px; border-radius: 4px; font-size: 11px; color: #6b7280;">
|
||||
Last used: {{ link.last_access_time|date:"Y-m-d H:i" }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="background: #fef2f2; color: #dc2626; padding: 4px 8px; border-radius: 4px; font-size: 11px;">
|
||||
Never used
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="color: #6b7280; font-style: italic; text-align: center; padding: 20px; background: #f9fafb; border-radius: 6px;">
|
||||
No access links configured for this server
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Add New Link Button -->
|
||||
<div style="margin-top: 16px; text-align: center;">
|
||||
<button type="button" class="add-link-btn"
|
||||
data-server-id="{{ data.server.id }}" data-server-name="{{ server_name }}"
|
||||
style="background: #10b981; color: white; padding: 8px 16px; border-radius: 6px; border: none; font-size: 12px; cursor: pointer; font-weight: bold;">
|
||||
➕ Add New Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Add Server Access -->
|
||||
{% if unassigned_servers %}
|
||||
<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px; padding: 16px; margin-top: 20px;">
|
||||
<h4 style="margin: 0 0 12px 0; color: #166534; font-size: 14px;">➕ Available Servers</h4>
|
||||
<div style="color: #166534; font-size: 12px; margin-bottom: 12px;">
|
||||
Click to instantly add access to these servers:
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
{% for server in unassigned_servers %}
|
||||
<button type="button" class="add-server-btn"
|
||||
data-server-id="{{ server.id }}" data-server-name="{{ server.name }}"
|
||||
style="background: #dcfce7; color: #166534; padding: 6px 12px; border-radius: 4px; font-size: 12px; border: 1px solid #bbf7d0; cursor: pointer; font-weight: bold;">
|
||||
{% if server.server_type == 'outline' %}🔵{% elif server.server_type == 'wireguard' %}🟢{% else %}⚪{% endif %}
|
||||
{{ server.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recent Activity Panel -->
|
||||
{% if original and recent_logs %}
|
||||
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin: 24px 0;">
|
||||
<h3 style="margin: 0 0 20px 0; color: #1f2937; font-size: 18px;">📈 Recent Activity (Last 30 days)</h3>
|
||||
|
||||
<div style="background: #fff; border-radius: 6px; overflow: hidden; border: 1px solid #e5e7eb;">
|
||||
<div style="background: #f9fafb; padding: 12px; border-bottom: 1px solid #e5e7eb; font-weight: bold; font-size: 14px;">
|
||||
Activity Log
|
||||
</div>
|
||||
|
||||
<div style="max-height: 400px; overflow-y: auto;">
|
||||
{% for log in recent_logs %}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 12px; border-bottom: 1px solid #f3f4f6;">
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
{% if log.action == 'Success' %}
|
||||
<span style="color: #16a34a; font-size: 16px;">✅</span>
|
||||
{% elif log.action == 'Failed' %}
|
||||
<span style="color: #dc2626; font-size: 16px;">❌</span>
|
||||
{% else %}
|
||||
<span style="color: #6b7280; font-size: 16px;">ℹ️</span>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<div style="font-weight: 500; font-size: 14px;">{{ log.server }}</div>
|
||||
{% if log.acl_link_id %}
|
||||
<div style="font-family: monospace; font-size: 12px; color: #6b7280;">
|
||||
{{ log.acl_link_id|slice:":16" }}{% if log.acl_link_id|length > 16 %}...{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: right;">
|
||||
<div style="font-size: 12px; color: #6b7280;">
|
||||
{{ log.timestamp|date:"Y-m-d H:i:s" }}
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #9ca3af;">
|
||||
{{ log.action }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.btn-hover:hover {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_change_form_document_ready %}
|
||||
{{ block.super }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userId = {{ original.id|default:"null" }};
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
|
||||
// Show success/error messages
|
||||
function showMessage(message, type = 'info') {
|
||||
const alertClass = type === 'error' ? 'alert-danger' : 'alert-success';
|
||||
const alertHtml = `
|
||||
<div class="alert ${alertClass} alert-dismissible fade show" role="alert" style="margin: 16px 0;">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Find a good place to insert the message
|
||||
const target = document.querySelector('.user-access-management') || document.querySelector('.content');
|
||||
if (target) {
|
||||
target.insertAdjacentHTML('afterbegin', alertHtml);
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
const alert = target.querySelector('.alert');
|
||||
if (alert) alert.remove();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new link functionality
|
||||
document.querySelectorAll('.add-link-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const serverId = this.dataset.serverId;
|
||||
const serverName = this.dataset.serverName;
|
||||
|
||||
const comment = prompt(`Add comment for new link on ${serverName} (optional):`, '');
|
||||
if (comment === null) return; // User cancelled
|
||||
|
||||
const originalText = this.textContent;
|
||||
this.textContent = '⏳ Adding...';
|
||||
this.classList.add('btn-loading');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/user/${userId}/add-link/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: `server_id=${serverId}&comment=${encodeURIComponent(comment)}`
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ New link created successfully: ${data.link}`, 'success');
|
||||
// Refresh the page to show the new link
|
||||
window.location.reload();
|
||||
} else {
|
||||
showMessage(`❌ Error: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ Network error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.textContent = originalText;
|
||||
this.classList.remove('btn-loading');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete link functionality
|
||||
document.querySelectorAll('.delete-link-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const linkId = this.dataset.linkId;
|
||||
const linkName = this.dataset.linkName;
|
||||
|
||||
if (!confirm(`Are you sure you want to delete link ${linkName}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = this.textContent;
|
||||
this.textContent = '⏳ Deleting...';
|
||||
this.classList.add('btn-loading');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/user/${userId}/delete-link/${linkId}/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ Link ${linkName} deleted successfully`, 'success');
|
||||
// Remove the link element from DOM
|
||||
this.closest('.link-container')?.remove() || window.location.reload();
|
||||
} else {
|
||||
showMessage(`❌ Error: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ Network error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.textContent = originalText;
|
||||
this.classList.remove('btn-loading');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add server access functionality
|
||||
document.querySelectorAll('.add-server-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const serverId = this.dataset.serverId;
|
||||
const serverName = this.dataset.serverName;
|
||||
|
||||
if (!confirm(`Add access to server ${serverName}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = this.textContent;
|
||||
this.textContent = '⏳ Adding...';
|
||||
this.classList.add('btn-loading');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/vpn/user/${userId}/add-server-access/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: `server_id=${serverId}`
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`✅ Access to ${serverName} added successfully`, 'success');
|
||||
// Refresh the page to show the new server section
|
||||
window.location.reload();
|
||||
} else {
|
||||
showMessage(`❌ Error: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`❌ Network error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this.textContent = originalText;
|
||||
this.classList.remove('btn-loading');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
Reference in New Issue
Block a user