Added outline server managment page template

This commit is contained in:
Ultradesu
2025-07-21 17:15:35 +03:00
parent 90001a1d1e
commit a75d55ac9d
9 changed files with 1411 additions and 366 deletions

View File

@@ -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('<int:object_id>/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 = '''
<div style="width: 100%;">
<textarea id="import-json-config" class="vLargeTextField" rows="8"
placeholder='{
"apiUrl": "https://your-server:port/path",
"certSha256": "your-certificate-hash",
"serverName": "My Outline Server",
"clientHostname": "your-server.com",
"clientPort": 1257,
"comment": "Server description"
}' style="font-family: 'Courier New', monospace; font-size: 0.875rem; width: 100%;"></textarea>
<div class="help" style="margin-top: 0.5rem;">
Paste JSON configuration from your Outline server setup to automatically fill the fields below.
</div>
<div style="margin-top: 1rem;">
<button type="button" id="import-json-btn" class="btn btn-primary" onclick="importJsonConfig()">Import Configuration</button>
</div>
<script>
function importJsonConfig() {
const textarea = document.getElementById('import-json-config');
try {
const jsonText = textarea.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
textarea.value = '';
alert('Configuration imported successfully! Review the fields below and save.');
// Click on Server Configuration tab if using Jazzmin
const serverTab = document.querySelector('a[href="#server-configuration-tab"]');
if (serverTab) {
serverTab.click();
}
} catch (error) {
alert('Invalid JSON format: ' + error.message);
}
}
// Add paste event listener
document.addEventListener('DOMContentLoaded', function() {
const textarea = document.getElementById('import-json-config');
if (textarea) {
textarea.addEventListener('paste', function(e) {
setTimeout(importJsonConfig, 100);
});
}
});
</script>
</div>
'''
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('<div style="color: #6c757d; font-style: italic;">Statistics will be available after saving</div>')
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 = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 0.375rem; padding: 1rem;">'
# Overall Statistics
html += '<div style="background: #e7f3ff; border-left: 4px solid #007cba; padding: 12px; margin-bottom: 16px; border-radius: 4px;">'
html += '<div style="display: flex; gap: 20px; margin-bottom: 8px; flex-wrap: wrap;">'
html += f'<div><strong>Total Users:</strong> {user_count}</div>'
html += f'<div><strong>Active Users (30d):</strong> {active_users_count}</div>'
html += f'<div><strong>Total Links:</strong> {total_links}</div>'
html += f'<div><strong>Server Keys:</strong> {server_keys_count}</div>'
html += '</div>'
html += '</div>'
# Get users data with ACL information
acls = ACL.objects.filter(server=obj).select_related('user').prefetch_related('links')
if acls:
html += '<h5 style="color: #495057; margin: 16px 0 8px 0;">👥 Users with Access</h5>'
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 += '<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
html += '<div style="flex: 1;">'
html += f'<div style="font-weight: 500; font-size: 14px; color: #495057;">{user.username}'
if user.comment:
html += f' <span style="color: #6c757d; font-size: 12px; font-weight: normal;">- {user.comment}</span>'
html += '</div>'
html += f'<div style="font-size: 12px; color: #6c757d;">{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 += '</div>'
html += '</div>'
# Status and actions
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>'
else:
html += '<span style="background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 10px;">❌ Missing</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 += '</div>'
html += '</div>'
else:
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 += '</div>'
html += '</div>'
return mark_safe(html)
except Exception as e:
return mark_safe(f'<div style="color: #dc3545;">Error loading statistics: {e}</div>')
@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('<div style="color: #6c757d; font-style: italic;">Export will be available after saving</div>')
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 = '''
<div>
<textarea id="export-json-config" class="vLargeTextField" rows="10" readonly
style="font-family: 'Courier New', monospace; font-size: 0.875rem; background-color: #f8f9fa; width: 100%;">''' + escaped_json + '''</textarea>
<div class="help" style="margin-top: 0.5rem;">
<strong>Includes:</strong> Server settings, connection details, live server info (if accessible), creation date, and comment.
</div>
<div style="padding-top: 1rem;">
<button type="button" id="copy-export-btn" class="btn btn-sm btn-secondary"
onclick="var btn=this; document.getElementById('export-json-config').select(); document.execCommand('copy'); btn.innerHTML='✅ Copied!'; setTimeout(function(){btn.innerHTML='📋 Copy JSON';}, 2000);"
style="margin-right: 10px;">📋 Copy JSON</button>
<button type="button" id="sync-server-btn" data-server-id="''' + str(obj.id) + '''" class="btn btn-sm btn-primary">🔄 Sync Server Settings</button>
<span style="margin-left: 0.5rem; font-size: 0.875rem; color: #6c757d;">
Synchronize server name, hostname, and port settings
</span>
</div>
</div>
'''
return mark_safe(html)
except Exception as e:
return mark_safe(f'<div style="color: #dc3545;">Error generating export: {e}</div>')
@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('<div style="color: #6c757d; font-style: italic;">Activity will be available after saving</div>')
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('<div style="color: #6c757d; font-style: italic; padding: 12px; background: #f8f9fa; border-radius: 4px;">No recent activity (last 7 days)</div>')
html = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 0; max-height: 300px; overflow-y: auto;">'
# Header
html += '<div style="background: #e9ecef; padding: 8px 12px; border-bottom: 1px solid #dee2e6; font-weight: 600; font-size: 12px; color: #495057;">'
html += f'📊 Access Log ({recent_logs.count()} entries, last 7 days)'
html += '</div>'
# 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'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; border-bottom: 1px solid #f1f3f4; background: {bg_color};">'
# Left side - user and link info
html += '<div style="display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0;">'
html += f'<span style="color: {status_color}; font-size: 14px;">{icon}</span>'
html += '<div style="overflow: hidden;">'
html += f'<div style="font-weight: 500; font-size: 12px; color: #495057; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{log.user}</div>'
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'<div style="font-family: monospace; font-size: 10px; color: #6c757d;">{link_short}</div>'
html += '</div></div>'
# Right side - timestamp and status
html += '<div style="text-align: right; flex-shrink: 0;">'
html += f'<div style="font-size: 10px; color: #6c757d;">{local_time.strftime("%m-%d %H:%M")}</div>'
html += f'<div style="font-size: 9px; color: {status_color}; font-weight: 500;">{log.action}</div>'
html += '</div>'
html += '</div>'
# 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'<div style="background: #e9ecef; padding: 6px 12px; font-size: 11px; color: #6c757d; text-align: center;">'
html += f'Showing 20 of {total_recent} entries from last 7 days'
html += '</div>'
html += '</div>'
return mark_safe(html)
except Exception as e:
return mark_safe(f'<div style="color: #dc3545; font-size: 12px;">Error loading activity: {e}</div>')
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)