From a75d55ac9d0348c6ff6b38b5ed47c9745ffbdf71 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Mon, 21 Jul 2025 17:15:35 +0300 Subject: [PATCH] Added outline server managment page template --- celerybeat-schedule-shm | Bin 32768 -> 0 bytes celerybeat-schedule-wal | Bin 65952 -> 0 bytes static/admin/css/vpn_admin.css | 146 ++++++ static/admin/js/generate_link.js | 183 +++++++ vpn/admin.py | 482 ++++++++++++++---- vpn/server_plugins/outline.py | 434 +++++++++++++++- .../vpn/outlineserver/add_form.html.backup | 176 +++++++ .../admin/vpn/outlineserver/change_form.html | 23 + vpn/templates/admin/vpn/user/change_form.html | 333 +++--------- 9 files changed, 1411 insertions(+), 366 deletions(-) delete mode 100644 celerybeat-schedule-shm delete mode 100644 celerybeat-schedule-wal create mode 100644 vpn/templates/admin/vpn/outlineserver/add_form.html.backup create mode 100644 vpn/templates/admin/vpn/outlineserver/change_form.html diff --git a/celerybeat-schedule-shm b/celerybeat-schedule-shm deleted file mode 100644 index 2f51277cc4fc6c4395f16a3f1002a97ed3e995e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI)&1nKr5CGs=HU1@d_ttImS diff --git a/celerybeat-schedule-wal b/celerybeat-schedule-wal deleted file mode 100644 index 7a67636bbb1c01909e0ef828a0e5e4393b337bf6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65952 zcmeI*e`pg|00;29CYrQ0UL3ZL)T)hR#A?zCt;!fH{$fbhGIZM?1vxI4*WQ`)lI7m% zTF7*+D>80ltk1%jf9@v;;xDE$WdHc5vOmUPv;H#%oBxcB{o(%DWbZXOjiSbB+tv6@ zNb>qF@5g=e1A6&#Z{vZ5hot0BPfJo%5|_R+$B&JlzIf%W$+J6Kwrwl%s)p+NAG^Ok zF>>0T`mt@VhL}FZV}CP3rVUuxulgJc9_?!6N?l_QW>IA=0$et2Fbb>+0Hu{VG}H2 z$Typ*qM2H57S3vvP;)O~-mT&U+u9Vm7HV0%dPCEJPQE4Mnie%}K1^tsP>s?e@6rp8 z=51oyLwQ~tJBr4iFjeKTHPpI+2w>Jt^0}@Ez=W}jab49bawSl&*1K}@ZB{%N9?Rxd1=HfjP?BL_xo&O z!Q~;pUrF>UcQa4!sq|dEcV39RQ80_dVg28H;fO76>ZxUc_)?eZDzUYUMY0Ejhf}Ls z1JNyXl)pL3|M5%G9n`6KTOi(}s{2UMG4r;n@~1Xd-of62-c(Pjo2B^Q0dez>7pyzF z@#uxCudyax8B{Jw%J0f0<(l$Cr5zq1009U<00Izz00bZa0SG_<0uY$50?mP#Tsf8$ zQvz|h>PW(}g@LF{M_Wa8)uDnmzALxo1%7#T%g2L3^5MXTEOHyz#W=LN*M1;jZ9>R1iC1_1~_00Izz00bZa0SG`~ z9tt2YfV{wMd4cx3oL^v8@&a$4?4ZZ@DPzbB%)_q*ObG%IfB*y_009U<00Izz00ba# zKY{z?1=`E!7pR#Rc>e0puLHaPZu9d3<+K1_m;e_DKmY;|fB*y_009U<00I!0LxHi# z3am#^N9z$N?d{TY+a6&(f;r0zoNI3CFPMMY$P3Kj#~njK00Izz00bZa0SG_<0uX=z z1ZF~@Dlf3&j`av0G%xVty5asChk7Qe@&aN-0<1?c6R!{YfB*y_009U<00Izz00bcL zzyin%)Yth1W+gAMayHf@*!yYv#^j#0H;@;2;14UF2muH{00Izz00bZa0SG_<0uY!V z0=4r3D{HMsu)MEx^z$|6`fue0L|Opr5zLPl7_)!?1Rwwb2tWV=5P$##o*V(>1&|lG zZ9RfjcUg~MR`LRu`zBfk_5?maUf{|3w!xl400Izz00bZa0SG_<0uX>eoe10~FR-e- z9zo5#K>KeeUjFFnyGQ)IKsha-Ox1}Hb^`(sfB*y_009U<00Izzz+)nSya4h7GmsbP z2*i6-bss4@X5LoSgTW(>l@EZopf}Z%>SihacR<|w&Ky5Be){5-wSSyly6HPNjJ&{O z@>PLpLI45~fB*y_009U<00Izz00ex2|DG4<;7uDubN{$N^Zxe-zr66*KYm_7oLfMd zicI=L;1L24fB*y_009U<00Izz00ibjU@Q_3#tuh`O^YUR`0u>eSjS)0?puUYSB(U@kuA7y|+jfB*y_009U<00Izz00bZ~g924~fw)+Bz@@rM OY%ODv>^;se(D5&%ST!#I diff --git a/static/admin/css/vpn_admin.css b/static/admin/css/vpn_admin.css index 05d175c..6522f05 100644 --- a/static/admin/css/vpn_admin.css +++ b/static/admin/css/vpn_admin.css @@ -92,6 +92,152 @@ white-space: nowrap; } +/* Server admin compact styles */ +.server-stats { + max-width: 120px; + min-width: 90px; +} + +.server-activity { + max-width: 140px; + min-width: 100px; +} + +.server-status { + max-width: 160px; + min-width: 120px; +} + +.server-comment { + max-width: 200px; + min-width: 100px; + word-wrap: break-word; +} + +/* Compact server display elements */ +.changelist-results .server-stats div, +.changelist-results .server-activity div, +.changelist-results .server-status div { + line-height: 1.3; + margin: 1px 0; +} + +/* Status indicator colors */ +.status-online { + color: #16a34a !important; + font-weight: bold; +} + +.status-error { + color: #dc2626 !important; + font-weight: bold; +} + +.status-warning { + color: #f97316 !important; + font-weight: bold; +} + +.status-unavailable { + color: #f97316 !important; + font-weight: bold; +} + +/* Activity indicators */ +.activity-high { + color: #16a34a !important; +} + +.activity-medium { + color: #eab308 !important; +} + +.activity-low { + color: #f97316 !important; +} + +.activity-none { + color: #dc2626 !important; +} + +/* User stats indicators */ +.users-active { + color: #16a34a !important; +} + +.users-medium { + color: #eab308 !important; +} + +.users-low { + color: #f97316 !important; +} + +.users-none { + color: #9ca3af !important; +} + +/* Table cell width constraints for better layout */ +table.changelist-results th:nth-child(1), /* Name */ +table.changelist-results td:nth-child(1) { + width: 180px; + max-width: 180px; +} + +table.changelist-results th:nth-child(3), /* Comment */ +table.changelist-results td:nth-child(3) { + width: 200px; + max-width: 200px; +} + +table.changelist-results th:nth-child(4), /* User Stats */ +table.changelist-results td:nth-child(4) { + width: 120px; + max-width: 120px; +} + +table.changelist-results th:nth-child(5), /* Activity */ +table.changelist-results td:nth-child(5) { + width: 140px; + max-width: 140px; +} + +table.changelist-results th:nth-child(6), /* Status */ +table.changelist-results td:nth-child(6) { + width: 160px; + max-width: 160px; +} + +/* Ensure text doesn't overflow in server admin */ +.changelist-results td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: top; +} + +/* Allow wrapping for multi-line server info displays */ +.changelist-results td .server-stats, +.changelist-results td .server-activity, +.changelist-results td .server-status { + white-space: normal; +} + +/* Server type icons */ +.server-type-outline { + color: #3b82f6; +} + +.server-type-wireguard { + color: #10b981; +} + +/* Tooltip styles for truncated text */ +[title] { + cursor: help; + border-bottom: 1px dotted #999; +} + /* Form improvements for move clients page */ .form-row.field-box { border: 1px solid #ddd; diff --git a/static/admin/js/generate_link.js b/static/admin/js/generate_link.js index 6e73e1d..c447b30 100644 --- a/static/admin/js/generate_link.js +++ b/static/admin/js/generate_link.js @@ -18,3 +18,186 @@ function generateRandomString(length) { return result; } +// OutlineServer JSON Configuration Functionality +document.addEventListener('DOMContentLoaded', function() { + + // JSON Import functionality + const importJsonBtn = document.getElementById('import-json-btn'); + const importJsonTextarea = document.getElementById('import-json-config'); + + if (importJsonBtn && importJsonTextarea) { + // Auto-fill on paste event + importJsonTextarea.addEventListener('paste', function(e) { + // Small delay to let paste complete + setTimeout(() => { + tryAutoFillFromJson(); + }, 100); + }); + + // Manual import button + importJsonBtn.addEventListener('click', function() { + tryAutoFillFromJson(); + }); + + function tryAutoFillFromJson() { + try { + const jsonText = importJsonTextarea.value.trim(); + if (!jsonText) { + alert('Please enter JSON configuration'); + return; + } + + const config = JSON.parse(jsonText); + + // Validate required fields + if (!config.apiUrl || !config.certSha256) { + alert('Invalid JSON format. Required fields: apiUrl, certSha256'); + return; + } + + // Parse apiUrl to extract components + const url = new URL(config.apiUrl); + + // Fill form fields + const adminUrlField = document.getElementById('id_admin_url'); + const adminCertField = document.getElementById('id_admin_access_cert'); + const clientHostnameField = document.getElementById('id_client_hostname'); + const clientPortField = document.getElementById('id_client_port'); + const nameField = document.getElementById('id_name'); + const commentField = document.getElementById('id_comment'); + + if (adminUrlField) adminUrlField.value = config.apiUrl; + if (adminCertField) adminCertField.value = config.certSha256; + + // Use provided hostname or extract from URL + const hostname = config.clientHostname || config.hostnameForAccessKeys || url.hostname; + if (clientHostnameField) clientHostnameField.value = hostname; + + // Use provided port or extract from various sources + const clientPort = config.clientPort || config.portForNewAccessKeys || url.port || '1257'; + if (clientPortField) clientPortField.value = clientPort; + + // Generate server name if not provided and field is empty + if (nameField && !nameField.value) { + const serverName = config.serverName || config.name || `Outline-${hostname}`; + nameField.value = serverName; + } + + // Fill comment if provided and field exists + if (commentField && config.comment) { + commentField.value = config.comment; + } + + // Clear the JSON input + importJsonTextarea.value = ''; + + // Show success message + showSuccessMessage('✅ Configuration imported successfully!'); + + } catch (error) { + alert('Invalid JSON format: ' + error.message); + } + } + } + + // Copy to clipboard functionality + window.copyToClipboard = function(elementId) { + const element = document.getElementById(elementId); + if (element) { + const text = element.textContent || element.innerText; + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => { + showCopySuccess(); + }).catch(err => { + fallbackCopyTextToClipboard(text); + }); + } else { + fallbackCopyTextToClipboard(text); + } + } + }; + + function fallbackCopyTextToClipboard(text) { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + document.execCommand('copy'); + showCopySuccess(); + } catch (err) { + console.error('Failed to copy text: ', err); + } + + document.body.removeChild(textArea); + } + + function showCopySuccess() { + showSuccessMessage('📋 Copied to clipboard!'); + } + + function showSuccessMessage(message) { + const alertHtml = ` +
+ ${message} + +
+ `; + + // Try to find a container for the message + const container = document.querySelector('.card-body') || document.querySelector('#content-main'); + if (container) { + container.insertAdjacentHTML('afterbegin', alertHtml); + } + + setTimeout(() => { + const alert = document.querySelector('.alert-success'); + if (alert) alert.remove(); + }, 5000); + } + + // Sync server button - handle both static and dynamic buttons + document.addEventListener('click', async function(e) { + if (e.target && (e.target.id === 'sync-server-btn' || e.target.matches('[id="sync-server-btn"]'))) { + const syncBtn = e.target; + const serverId = syncBtn.dataset.serverId; + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; + + const originalText = syncBtn.textContent; + syncBtn.textContent = 'âŗ Syncing...'; + syncBtn.disabled = true; + + try { + const response = await fetch(`/admin/vpn/outlineserver/${serverId}/sync/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': csrfToken + } + }); + + const data = await response.json(); + + if (data.success) { + showSuccessMessage(`✅ ${data.message}`); + setTimeout(() => window.location.reload(), 2000); + } else { + alert('Sync failed: ' + data.error); + } + } catch (error) { + alert('Network error: ' + error.message); + } finally { + syncBtn.textContent = originalText; + syncBtn.disabled = false; + } + } + }); +}); diff --git a/vpn/admin.py b/vpn/admin.py index a346322..e571569 100644 --- a/vpn/admin.py +++ b/vpn/admin.py @@ -247,10 +247,10 @@ class LastAccessFilter(admin.SimpleListFilter): class ServerAdmin(PolymorphicParentModelAdmin): base_model = Server child_models = (OutlineServer, WireguardServer) - list_display = ('name', 'server_type', 'comment', 'registration_date', 'user_count', 'server_status_inline') + list_display = ('name_with_icon', 'server_type', 'comment_short', 'user_stats', 'activity_summary', 'server_status_compact', 'registration_date') search_fields = ('name', 'comment') list_filter = ('server_type', ) - actions = ['move_clients_action', 'purge_all_keys_action'] + actions = ['move_clients_action', 'purge_all_keys_action', 'sync_all_selected_servers'] def get_urls(self): urls = super().get_urls() @@ -549,24 +549,213 @@ class ServerAdmin(PolymorphicParentModelAdmin): ) purge_all_keys_action.short_description = "đŸ—‘ī¸ Purge all keys from server (database unchanged)" + + def sync_all_selected_servers(self, request, queryset): + """Trigger sync for all users on selected servers""" + if queryset.count() == 0: + self.message_user(request, "Please select at least one server.", level=messages.ERROR) + return + + try: + from vpn.tasks import sync_all_users_on_server + + tasks_started = 0 + errors = [] + + for server in queryset: + try: + task = sync_all_users_on_server.delay(server.id) + tasks_started += 1 + self.message_user( + request, + f"🔄 Sync task started for '{server.name}' (Task ID: {task.id})", + level=messages.SUCCESS + ) + except Exception as e: + errors.append(f"'{server.name}': {e}") + + if tasks_started > 0: + self.message_user( + request, + f"✅ Started sync tasks for {tasks_started} server(s). Check Task Execution Logs for progress.", + level=messages.SUCCESS + ) + + if errors: + for error in errors: + self.message_user( + request, + f"❌ Failed to sync {error}", + level=messages.ERROR + ) + + except Exception as e: + self.message_user( + request, + f"❌ Failed to start sync tasks: {e}", + level=messages.ERROR + ) + + sync_all_selected_servers.short_description = "🔄 Sync all users on selected servers" - @admin.display(description='User Count', ordering='user_count') - def user_count(self, obj): - return obj.user_count - + @admin.display(description='Server', ordering='name') + def name_with_icon(self, obj): + """Display server name with type icon""" + icons = { + 'outline': 'đŸ”ĩ', + 'wireguard': 'đŸŸĸ', + } + icon = icons.get(obj.server_type, '') + name_part = f"{icon} {obj.name}" if icon else obj.name + return name_part + + @admin.display(description='Comment') + def comment_short(self, obj): + """Display shortened comment""" + if obj.comment: + short_comment = obj.comment[:40] + '...' if len(obj.comment) > 40 else obj.comment + return mark_safe(f'{short_comment}') + return '-' + + @admin.display(description='Users & Links') + def user_stats(self, obj): + """Display user count and active links statistics""" + try: + from django.utils import timezone + from datetime import timedelta + + user_count = obj.user_count if hasattr(obj, 'user_count') else 0 + + # Get total links count + total_links = ACLLink.objects.filter(acl__server=obj).count() + + # Get recently accessed links (last 30 days) - exclude None values + 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 + if user_count == 0: + color = '#9ca3af' # gray - no users + elif total_links == 0: + color = '#dc2626' # red - no links + elif active_links > total_links * 0.7: # High activity + color = '#16a34a' # green + elif active_links > total_links * 0.3: # Medium activity + color = '#eab308' # yellow + else: + color = '#f97316' # orange - low activity + + return mark_safe( + f'
' + + f'
đŸ‘Ĩ {user_count} users
' + + f'
🔗 {active_links}/{total_links} active
' + + f'
' + ) + except Exception as e: + return mark_safe(f'Stats error: {e}') + + @admin.display(description='Activity') + def activity_summary(self, obj): + """Display recent activity summary""" + try: + from django.utils import timezone + from datetime import timedelta + + # 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( + f'
' + + f'
📊 {total_access} uses (7d)
' + + f'
✅ {success_access} success
' + + f'
🕒 {latest_str} {time_ago}
' + + f'
' + ) + except Exception as e: + return mark_safe(f'Activity unavailable') + @admin.display(description='Status') - def server_status_inline(self, obj): + def server_status_compact(self, obj): + """Display server status in compact format""" try: status = obj.get_server_status() if 'error' in status: - return mark_safe(f"Error: {status['error']}") - import json - pretty_status = ", ".join(f"{key}: {value}" for key, value in status.items()) - return mark_safe(f"
{pretty_status}
") + return mark_safe( + f'
' + + f'❌ Error
' + + f'' + + f'{status["error"][:30]}...' + + f'
' + ) + + # 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 = '
'.join(status_items) if status_items else 'Unknown' + + return mark_safe( + f'
' + + f'✅ Online
' + + f'{status_display}' + + f'
' + ) except Exception as e: # Don't let server connectivity issues break the admin interface - return mark_safe(f"Status unavailable: {e}") - server_status_inline.short_description = "Status" + return mark_safe( + f'
' + + f'âš ī¸ Unavailable
' + + f'' + + f'{str(e)[:25]}...' + + f'
' + ) def get_queryset(self, request): qs = super().get_queryset(request) @@ -608,6 +797,7 @@ class UserAdmin(admin.ModelAdmin): @admin.display(description='User Statistics Summary') def user_statistics_summary(self, obj): + """Display user statistics with integrated server management""" try: from .models import UserStatistics from django.db import models @@ -626,71 +816,219 @@ class UserAdmin(admin.ModelAdmin): links=models.Count('id') ).order_by('-connections') - html = '
' - html += f'
' + # Get all ACLs and links for this user + user_acls = ACL.objects.filter(user=obj).select_related('server').prefetch_related('links') + + # Get available servers not yet assigned + all_servers = Server.objects.all() + assigned_server_ids = [acl.server.id for acl in user_acls] + unassigned_servers = all_servers.exclude(id__in=assigned_server_ids) + + html = '
' + + # Overall Statistics + html += '
' + html += f'
' html += f'
Total Uses: {user_stats["total_connections"] or 0}
' html += f'
Recent (30d): {user_stats["recent_connections"] or 0}
' html += f'
Total Links: {user_stats["total_links"] or 0}
' if user_stats["max_daily_peak"]: html += f'
Daily Peak: {user_stats["max_daily_peak"]}
' html += f'
' - - if server_breakdown: - html += '
By Server:
' - html += '
    ' - for server in server_breakdown: - html += f'
  • {server["server_name"]}: {server["connections"]} uses ({server["links"]} links)
  • ' - html += '
' - html += '
' + + # Server Management + if user_acls: + html += '

🔗 Server Access & Links

' + + for acl in user_acls: + server = acl.server + links = list(acl.links.all()) + + # Server status check + 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 += '
' + + # Server header + status_icon = '✅' if server_accessible else '❌' + type_icon = 'đŸ”ĩ' if server.server_type == 'outline' else 'đŸŸĸ' if server.server_type == 'wireguard' else '' + html += f'
' + html += f'
{type_icon} {server.name} {status_icon}
' + + # Server stats + server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None) + if server_stat: + html += f'' + html += f'📊 {server_stat["connections"]} uses ({server_stat["links"]} links)' + html += f'' + html += f'
' + + # Server error display + if server_error: + html += f'
' + html += f'âš ī¸ Error: {server_error[:80]}...' + html += f'
' + + # Links display + if links: + for link in links: + # Get link stats + link_stats = UserStatistics.objects.filter( + user=obj, server_name=server.name, acl_link_id=link.link + ).first() + + html += '' + + # Add link button + html += f'
' + html += f'' + html += f'
' + + html += '
' # End server-section + + # Add server access section + if unassigned_servers: + html += '
' + html += '
➕ Available Servers
' + html += '
' + for server in unassigned_servers: + type_icon = 'đŸ”ĩ' if server.server_type == 'outline' else 'đŸŸĸ' if server.server_type == 'wireguard' else '' + html += f'' + html += '
' + + html += '
' # End user-management-section return mark_safe(html) + except Exception as e: - return mark_safe(f'Error loading statistics: {e}') + return mark_safe(f'Error loading management interface: {e}') @admin.display(description='Recent Activity') def recent_activity_display(self, obj): + """Display recent activity in compact admin-friendly format""" try: from datetime import timedelta from django.utils import timezone - # Get recent access logs for this user + # Get recent access logs for this user (last 7 days, limited) + seven_days_ago = timezone.now() - timedelta(days=7) recent_logs = AccessLog.objects.filter( user=obj.username, - timestamp__gte=timezone.now() - timedelta(days=7) - ).order_by('-timestamp')[:10] + timestamp__gte=seven_days_ago + ).order_by('-timestamp')[:15] # Limit to 15 most recent if not recent_logs: - return mark_safe('
No recent activity
') + return mark_safe('
No recent activity (last 7 days)
') - html = '
' - html += '
Last 7 days:
' + html = '
' - for log in recent_logs: + # Header + html += '
' + html += f'📊 Recent Activity ({recent_logs.count()} entries, last 7 days)' + html += '
' + + # Activity entries + for i, log in enumerate(recent_logs): + bg_color = '#ffffff' if i % 2 == 0 else '#f8f9fa' local_time = localtime(log.timestamp) - time_str = local_time.strftime('%Y-%m-%d %H:%M') - # Status color coding + # Status icon and color if log.action == 'Success': - color = '#16a34a' icon = '✅' + status_color = '#28a745' elif log.action == 'Failed': - color = '#dc2626' icon = '❌' + status_color = '#dc3545' else: - color = '#6b7280' icon = 'â„šī¸' + status_color = '#6c757d' - 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'
' - html += f'
' - html += f'{icon} {log.server} / {link_display}' - html += f'{time_str}' + # Left side - server and link info + html += f'
' + html += f'{icon}' + html += f'
' + html += f'
{log.server}
' + + if log.acl_link_id: + link_short = log.acl_link_id[:12] + '...' if len(log.acl_link_id) > 12 else log.acl_link_id + html += f'
{link_short}
' + + html += f'
' + + # Right side - timestamp and status + html += f'
' + html += f'
{local_time.strftime("%m-%d %H:%M")}
' + html += f'
{log.action}
' + html += f'
' + + html += f'
' + + # Footer with summary if there are more entries + total_recent = AccessLog.objects.filter( + user=obj.username, + timestamp__gte=seven_days_ago + ).count() + + if total_recent > 15: + html += f'
' + html += f'Showing 15 of {total_recent} entries from last 7 days' html += f'
' html += '
' return mark_safe(html) + except Exception as e: - return mark_safe(f'Error loading activity: {e}') + return mark_safe(f'Error loading activity: {e}') @admin.display(description='Allowed servers', ordering='server_count') def server_count(self, obj): @@ -798,74 +1136,14 @@ class UserAdmin(admin.ModelAdmin): 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""" + """Override change view to add user management data to context""" 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, }) diff --git a/vpn/server_plugins/outline.py b/vpn/server_plugins/outline.py index f2af1a1..08f95f7 100644 --- a/vpn/server_plugins/outline.py +++ b/vpn/server_plugins/outline.py @@ -1,6 +1,9 @@ import logging +import json import requests from django.db import models +from django.shortcuts import render, redirect +from django.conf import settings from .generic import Server from urllib3 import PoolManager from outline_vpn.outline_vpn import OutlineVPN, OutlineServerErrorException @@ -301,9 +304,50 @@ class OutlineServerAdmin(PolymorphicChildModelAdmin): 'user_count', 'server_status_inline', ) - readonly_fields = ('server_status_full', 'registration_date',) + readonly_fields = ('server_status_full', 'registration_date', 'export_configuration_display', 'server_statistics_display', 'recent_activity_display', 'json_import_field') list_editable = ('admin_url', 'admin_access_cert', 'client_hostname', 'client_port',) exclude = ('server_type',) + + def get_fieldsets(self, request, obj=None): + """Customize fieldsets based on whether object exists""" + if obj is None: # Adding new server + return ( + ('JSON Import', { + 'fields': ('json_import_field',), + 'description': 'Quick import from Outline server JSON configuration' + }), + ('Server Configuration', { + 'fields': ('name', 'comment', 'admin_url', 'admin_access_cert', 'client_hostname', 'client_port') + }), + ) + else: # Editing existing server + return ( + ('Server Configuration', { + 'fields': ('name', 'comment', 'admin_url', 'admin_access_cert', 'client_hostname', 'client_port', 'registration_date') + }), + ('Server Status', { + 'fields': ('server_status_full',) + }), + ('Export Configuration', { + 'fields': ('export_configuration_display',) + }), + ('Statistics & Users', { + 'fields': ('server_statistics_display',), + 'classes': ('collapse',) + }), + ('Recent Activity', { + 'fields': ('recent_activity_display',), + 'classes': ('collapse',) + }), + ) + + def get_urls(self): + from django.urls import path + urls = super().get_urls() + custom_urls = [ + path('/sync/', self.admin_site.admin_view(self.sync_server_view), name='outlineserver_sync'), + ] + return custom_urls + urls @admin.display(description='Clients', ordering='user_count') def user_count(self, obj): @@ -336,8 +380,396 @@ class OutlineServerAdmin(PolymorphicChildModelAdmin): server_status_full.short_description = "Server Status" + def sync_server_view(self, request, object_id): + """AJAX view to sync server settings""" + from django.http import JsonResponse + + if request.method == 'POST': + try: + server = OutlineServer.objects.get(pk=object_id) + result = server.sync() + + return JsonResponse({ + 'success': True, + 'message': f'Server "{server.name}" synchronized successfully', + 'details': result + }) + + except Exception as e: + return JsonResponse({ + 'success': False, + 'error': str(e) + }, status=500) + + return JsonResponse({'error': 'Invalid request method'}, status=405) + + def add_view(self, request, form_url='', extra_context=None): + """Use the default Django admin add view""" + return super().add_view(request, form_url, extra_context) + + @admin.display(description='Import JSON Configuration') + def json_import_field(self, obj): + """Display JSON import field for new servers only""" + if obj and obj.pk: + # Hide for existing servers + return '' + + html = ''' +
+ +
+ Paste JSON configuration from your Outline server setup to automatically fill the fields below. +
+
+ +
+ +
+ ''' + + return mark_safe(html) + + @admin.display(description='Server Statistics & Users') + def server_statistics_display(self, obj): + """Display server statistics and user management""" + if not obj or not obj.pk: + return mark_safe('
Statistics will be available after saving
') + + try: + from vpn.models import ACL, AccessLog, UserStatistics + from django.utils import timezone + from datetime import timedelta + + # Get user statistics + user_count = ACL.objects.filter(server=obj).count() + total_links = 0 + server_keys_count = 0 + + try: + from vpn.models import ACLLink + total_links = ACLLink.objects.filter(acl__server=obj).count() + + # Try to get actual keys count from server + server_status = obj.get_server_status() + if 'keys' in server_status: + server_keys_count = server_status['keys'] + except Exception: + pass + + # Get active users count (last 30 days) + thirty_days_ago = timezone.now() - timedelta(days=30) + active_users_count = UserStatistics.objects.filter( + server_name=obj.name, + recent_connections__gt=0 + ).values('user').distinct().count() + + html = '
' + + # Overall Statistics + html += '
' + html += '
' + html += f'
Total Users: {user_count}
' + html += f'
Active Users (30d): {active_users_count}
' + html += f'
Total Links: {total_links}
' + html += f'
Server Keys: {server_keys_count}
' + html += '
' + html += '
' + + # Get users data with ACL information + acls = ACL.objects.filter(server=obj).select_related('user').prefetch_related('links') + + if acls: + html += '
đŸ‘Ĩ Users with Access
' + + for acl in acls: + user = acl.user + links = list(acl.links.all()) + + # Get last access time from any link + last_access = None + for link in links: + if link.last_access_time: + if last_access is None or link.last_access_time > last_access: + last_access = link.last_access_time + + # Check if user has key on server + server_key = False + try: + obj.get_user(user) + server_key = True + except Exception: + pass + + html += '
' + + # User info + html += '
' + html += f'
{user.username}' + if user.comment: + html += f' - {user.comment}' + html += '
' + html += f'
{len(links)} link(s)' + if last_access: + from django.utils.timezone import localtime + local_time = localtime(last_access) + html += f' | Last access: {local_time.strftime("%Y-%m-%d %H:%M")}' + else: + html += ' | Never accessed' + html += '
' + html += '
' + + # Status and actions + html += '
' + if server_key: + html += '✅ On Server' + else: + html += '❌ Missing' + + html += f'👤 Edit' + html += '
' + html += '
' + else: + html += '
' + html += 'No users assigned to this server' + html += '
' + + html += '
' + return mark_safe(html) + + except Exception as e: + return mark_safe(f'
Error loading statistics: {e}
') + + @admin.display(description='Export Configuration') + def export_configuration_display(self, obj): + """Display JSON export configuration""" + if not obj or not obj.pk: + return mark_safe('
Export will be available after saving
') + + try: + # Build export data + export_data = { + 'apiUrl': obj.admin_url, + 'certSha256': obj.admin_access_cert, + 'serverName': obj.name, + 'clientHostname': obj.client_hostname, + 'clientPort': int(obj.client_port), + 'comment': obj.comment, + 'serverType': 'outline', + 'dateCreated': obj.registration_date.isoformat() if obj.registration_date else None, + 'id': obj.id + } + + # Try to get server status + try: + server_status = obj.get_server_status() + if 'error' not in server_status: + export_data['serverInfo'] = server_status + except Exception: + pass + + json_str = json.dumps(export_data, indent=2) + # Escape the JSON for HTML + from django.utils.html import escape + escaped_json = escape(json_str) + + html = ''' +
+ +
+ Includes: Server settings, connection details, live server info (if accessible), creation date, and comment. +
+
+ + + + Synchronize server name, hostname, and port settings + +
+
+ ''' + + return mark_safe(html) + + except Exception as e: + return mark_safe(f'
Error generating export: {e}
') + + @admin.display(description='Recent Activity') + def recent_activity_display(self, obj): + """Display recent activity in admin-friendly format""" + if not obj or not obj.pk: + return mark_safe('
Activity will be available after saving
') + + try: + from vpn.models import AccessLog + from django.utils.timezone import localtime + from datetime import timedelta + from django.utils import timezone + + # Get recent access logs for this server (last 7 days) + seven_days_ago = timezone.now() - timedelta(days=7) + recent_logs = AccessLog.objects.filter( + server=obj.name, + timestamp__gte=seven_days_ago + ).order_by('-timestamp')[:20] + + if not recent_logs: + return mark_safe('
No recent activity (last 7 days)
') + + html = '
' + + # Header + html += '
' + html += f'📊 Access Log ({recent_logs.count()} entries, last 7 days)' + html += '
' + + # Activity entries + for i, log in enumerate(recent_logs): + bg_color = '#ffffff' if i % 2 == 0 else '#f8f9fa' + local_time = localtime(log.timestamp) + + # Status icon and color + if log.action == 'Success': + icon = '✅' + status_color = '#28a745' + elif log.action == 'Failed': + icon = '❌' + status_color = '#dc3545' + else: + icon = 'â„šī¸' + status_color = '#6c757d' + + html += f'
' + + # Left side - user and link info + html += '
' + html += f'{icon}' + html += '
' + html += f'
{log.user}
' + + if log.acl_link_id: + link_short = log.acl_link_id[:12] + '...' if len(log.acl_link_id) > 12 else log.acl_link_id + html += f'
{link_short}
' + + html += '
' + + # Right side - timestamp and status + html += '
' + html += f'
{local_time.strftime("%m-%d %H:%M")}
' + html += f'
{log.action}
' + html += '
' + + html += '
' + + # Footer with summary if there are more entries + total_recent = AccessLog.objects.filter( + server=obj.name, + timestamp__gte=seven_days_ago + ).count() + + if total_recent > 20: + html += f'
' + html += f'Showing 20 of {total_recent} entries from last 7 days' + html += '
' + + html += '
' + return mark_safe(html) + + except Exception as e: + return mark_safe(f'
Error loading activity: {e}
') + def get_model_perms(self, request): """It disables display for sub-model""" return {} + class Media: + js = ('admin/js/generate_link.js',) + css = {'all': ('admin/css/vpn_admin.css',)} + admin.site.register(OutlineServer, OutlineServerAdmin) diff --git a/vpn/templates/admin/vpn/outlineserver/add_form.html.backup b/vpn/templates/admin/vpn/outlineserver/add_form.html.backup new file mode 100644 index 0000000..00cda78 --- /dev/null +++ b/vpn/templates/admin/vpn/outlineserver/add_form.html.backup @@ -0,0 +1,176 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls static %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} diff --git a/vpn/templates/admin/vpn/outlineserver/change_form.html b/vpn/templates/admin/vpn/outlineserver/change_form.html new file mode 100644 index 0000000..37adbb8 --- /dev/null +++ b/vpn/templates/admin/vpn/outlineserver/change_form.html @@ -0,0 +1,23 @@ +{% extends "admin/change_form.html" %} +{% load static %} + +{% block content_title %} +

+ {% if original %} + đŸ”ĩ Outline Server: {{ original.name }} + {% else %} + đŸ”ĩ Add Outline Server + {% endif %} +

+{% endblock %} + +{% block admin_change_form_document_ready %} + {{ block.super }} + +{% endblock %} + +{% block field_sets %} + {{ block.super }} +{% endblock %} diff --git a/vpn/templates/admin/vpn/user/change_form.html b/vpn/templates/admin/vpn/user/change_form.html index 8cfbc16..e87b535 100644 --- a/vpn/templates/admin/vpn/user/change_form.html +++ b/vpn/templates/admin/vpn/user/change_form.html @@ -11,272 +11,81 @@ {% endblock %} -{% block content %} - {{ block.super }} - - {% if original and servers_data %} - -
-

🔗 User Access Management

- - -
-

📱 User Portal Access

-
- - 🌐 Open User Portal - - - 📄 JSON API - - - Hash: {{ original.hash }} - -
-
- - -
- {% for server_name, data in servers_data.items %} -
-
-

- {% if data.server.server_type == 'outline' %}đŸ”ĩ{% elif data.server.server_type == 'wireguard' %}đŸŸĸ{% else %}âšĒ{% endif %} - {{ server_name }} -

-
- {% if data.accessible %} - - ✅ Online - - {% else %} - - ❌ Offline - - {% endif %} - - - {% for stat in data.statistics %} - {% if not stat.acl_link_id %} - - 📊 {{ stat.total_connections }} uses - - {% endif %} - {% endfor %} -
-
- - {% if data.error %} -
-
âš ī¸ Server Error:
-
{{ data.error }}
-
- {% endif %} - - - {% if data.status %} -
-
Server Status:
-
- {% for key, value in data.status.items %} - {{ key }}: {{ value }} - {% endfor %} -
-
- {% endif %} - - -
-
- Access Links ({{ data.links|length }}): -
- - {% if data.links %} -
- {% for link in data.links %} - - {% endfor %} -
- {% else %} -
- No access links configured for this server -
- {% endif %} - - -
- -
-
-
- {% endfor %} -
- - - {% if unassigned_servers %} -
-

➕ Available Servers

-
- Click to instantly add access to these servers: -
-
- {% for server in unassigned_servers %} - - {% endfor %} -
-
- {% endif %} -
- {% endif %} - - - {% if original and recent_logs %} -
-

📈 Recent Activity (Last 30 days)

- -
-
- Activity Log -
- -
- {% for log in recent_logs %} -
-
- {% if log.action == 'Success' %} - ✅ - {% elif log.action == 'Failed' %} - ❌ - {% else %} - â„šī¸ - {% endif %} - -
-
{{ log.server }}
- {% if log.acl_link_id %} -
- {{ log.acl_link_id|slice:":16" }}{% if log.acl_link_id|length > 16 %}...{% endif %} -
- {% endif %} -
-
- -
-
- {{ log.timestamp|date:"Y-m-d H:i:s" }} -
-
- {{ log.action }} -
-
-
- {% endfor %} -
-
-
- {% endif %} -{% endblock %} - {% block extrahead %} {{ block.super }} {% endblock %} {% block admin_change_form_document_ready %} {{ block.super }} + {% if original %} + {% endif %} {% endblock %}