mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Added outline server managment page template
This commit is contained in:
@@ -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)
|
||||
|
Reference in New Issue
Block a user