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

Binary file not shown.

Binary file not shown.

View File

@@ -92,6 +92,152 @@
white-space: nowrap; 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 improvements for move clients page */
.form-row.field-box { .form-row.field-box {
border: 1px solid #ddd; border: 1px solid #ddd;

View File

@@ -18,3 +18,186 @@ function generateRandomString(length) {
return result; 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 = `
<div class="alert alert-success alert-dismissible" style="margin: 1rem 0;">
${message}
<button type="button" class="close" aria-label="Close" onclick="this.parentElement.remove()">
<span aria-hidden="true">&times;</span>
</button>
</div>
`;
// 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;
}
}
});
});

View File

@@ -247,10 +247,10 @@ class LastAccessFilter(admin.SimpleListFilter):
class ServerAdmin(PolymorphicParentModelAdmin): class ServerAdmin(PolymorphicParentModelAdmin):
base_model = Server base_model = Server
child_models = (OutlineServer, WireguardServer) child_models = (OutlineServer, WireguardServer)
list_display = ('name', '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') search_fields = ('name', 'comment')
list_filter = ('server_type', ) 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): def get_urls(self):
urls = super().get_urls() urls = super().get_urls()
@@ -550,23 +550,212 @@ class ServerAdmin(PolymorphicParentModelAdmin):
purge_all_keys_action.short_description = "🗑️ Purge all keys from server (database unchanged)" purge_all_keys_action.short_description = "🗑️ Purge all keys from server (database unchanged)"
@admin.display(description='User Count', ordering='user_count') def sync_all_selected_servers(self, request, queryset):
def user_count(self, obj): """Trigger sync for all users on selected servers"""
return obj.user_count 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='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'<span title="{obj.comment}" style="font-size: 12px;">{short_comment}</span>')
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'<div style="font-size: 12px;">' +
f'<div style="color: {color}; font-weight: bold;">👥 {user_count} users</div>' +
f'<div style="color: #6b7280;">🔗 {active_links}/{total_links} active</div>' +
f'</div>'
)
except Exception as e:
return mark_safe(f'<span style="color: #dc2626; font-size: 11px;">Stats error: {e}</span>')
@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'<div style="font-size: 11px;">' +
f'<div style="color: {color}; font-weight: bold;">📊 {total_access} uses (7d)</div>' +
f'<div style="color: #6b7280;">✅ {success_access} success</div>' +
f'<div style="color: #6b7280;">🕒 {latest_str} {time_ago}</div>' +
f'</div>'
)
except Exception as e:
return mark_safe(f'<span style="color: #dc2626; font-size: 11px;">Activity unavailable</span>')
@admin.display(description='Status') @admin.display(description='Status')
def server_status_inline(self, obj): def server_status_compact(self, obj):
"""Display server status in compact format"""
try: try:
status = obj.get_server_status() status = obj.get_server_status()
if 'error' in status: if 'error' in status:
return mark_safe(f"<span style='color: red;'>Error: {status['error']}</span>") return mark_safe(
import json f'<div style="color: #dc2626; font-size: 11px; font-weight: bold;">' +
pretty_status = ", ".join(f"{key}: {value}" for key, value in status.items()) f'❌ Error<br>' +
return mark_safe(f"<pre>{pretty_status}</pre>") f'<span style="font-weight: normal;" title="{status["error"]}">' +
f'{status["error"][:30]}...</span>' +
f'</div>'
)
# Extract key metrics
status_items = []
if 'name' in status:
status_items.append(f"📛 {status['name'][:15]}")
if 'version' in status:
status_items.append(f"🔄 {status['version']}")
if 'keys' in status:
status_items.append(f"🔑 {status['keys']} keys")
if 'accessUrl' in status:
status_items.append("🌐 Available")
status_display = '<br>'.join(status_items) if status_items else 'Unknown'
return mark_safe(
f'<div style="color: #16a34a; font-size: 11px;">' +
f'✅ Online<br>' +
f'<span style="color: #6b7280;">{status_display}</span>' +
f'</div>'
)
except Exception as e: except Exception as e:
# Don't let server connectivity issues break the admin interface # Don't let server connectivity issues break the admin interface
return mark_safe(f"<span style='color: orange;'>Status unavailable: {e}</span>") return mark_safe(
server_status_inline.short_description = "Status" f'<div style="color: #f97316; font-size: 11px; font-weight: bold;">' +
f'⚠️ Unavailable<br>' +
f'<span style="font-weight: normal;" title="{str(e)}">' +
f'{str(e)[:25]}...</span>' +
f'</div>'
)
def get_queryset(self, request): def get_queryset(self, request):
qs = super().get_queryset(request) qs = super().get_queryset(request)
@@ -608,6 +797,7 @@ class UserAdmin(admin.ModelAdmin):
@admin.display(description='User Statistics Summary') @admin.display(description='User Statistics Summary')
def user_statistics_summary(self, obj): def user_statistics_summary(self, obj):
"""Display user statistics with integrated server management"""
try: try:
from .models import UserStatistics from .models import UserStatistics
from django.db import models from django.db import models
@@ -626,71 +816,219 @@ class UserAdmin(admin.ModelAdmin):
links=models.Count('id') links=models.Count('id')
).order_by('-connections') ).order_by('-connections')
html = '<div style="background: #f8f9fa; padding: 12px; border-radius: 6px; margin: 8px 0;">' # Get all ACLs and links for this user
html += f'<div style="display: flex; gap: 20px; margin-bottom: 12px; flex-wrap: wrap;">' 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 = '<div class="user-management-section">'
# Overall Statistics
html += '<div style="background: #e7f3ff; border-left: 4px solid #007cba; padding: 12px; margin-bottom: 16px; border-radius: 4px;">'
html += f'<div style="display: flex; gap: 20px; margin-bottom: 8px; flex-wrap: wrap;">'
html += f'<div><strong>Total Uses:</strong> {user_stats["total_connections"] or 0}</div>' 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>Recent (30d):</strong> {user_stats["recent_connections"] or 0}</div>'
html += f'<div><strong>Total Links:</strong> {user_stats["total_links"] or 0}</div>' html += f'<div><strong>Total Links:</strong> {user_stats["total_links"] or 0}</div>'
if user_stats["max_daily_peak"]: if user_stats["max_daily_peak"]:
html += f'<div><strong>Daily Peak:</strong> {user_stats["max_daily_peak"]}</div>' html += f'<div><strong>Daily Peak:</strong> {user_stats["max_daily_peak"]}</div>'
html += f'</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>' html += '</div>'
# Server Management
if user_acls:
html += '<h4 style="color: #007cba; margin: 16px 0 8px 0;">🔗 Server Access & Links</h4>'
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 += '<div class="server-section">'
# Server header
status_icon = '' if server_accessible else ''
type_icon = '🔵' if server.server_type == 'outline' else '🟢' if server.server_type == 'wireguard' else ''
html += f'<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">'
html += f'<h5 style="margin: 0; color: #333; font-size: 14px; font-weight: 600;">{type_icon} {server.name} {status_icon}</h5>'
# Server stats
server_stat = next((s for s in server_breakdown if s['server_name'] == server.name), None)
if server_stat:
html += f'<span style="background: #f8f9fa; padding: 4px 8px; border-radius: 4px; font-size: 12px; color: #6c757d;">'
html += f'📊 {server_stat["connections"]} uses ({server_stat["links"]} links)'
html += f'</span>'
html += f'</div>'
# Server error display
if server_error:
html += f'<div style="background: #f8d7da; color: #721c24; padding: 8px; border-radius: 4px; margin-bottom: 8px; font-size: 12px;">'
html += f'⚠️ Error: {server_error[:80]}...'
html += f'</div>'
# Links display
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 += '<div class="link-item">'
html += f'<div style="flex: 1;">'
html += f'<div style="font-family: monospace; font-size: 13px; color: #007cba; font-weight: 500;">'
html += f'{link.link[:16]}...' if len(link.link) > 16 else link.link
html += f'</div>'
if link.comment:
html += f'<div style="font-size: 11px; color: #6c757d;">{link.comment}</div>'
html += f'</div>'
# Link stats and actions
html += f'<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">'
if link_stats:
html += f'<span style="background: #d4edda; color: #155724; padding: 2px 6px; border-radius: 3px; font-size: 10px;">'
html += f'{link_stats.total_connections}'
html += f'</span>'
# Test link button
html += f'<a href="{EXTERNAL_ADDRESS}/ss/{link.link}#{server.name}" target="_blank" '
html += f'class="btn btn-sm btn-primary btn-sm-custom">🔗</a>'
# Delete button
html += f'<button type="button" class="btn btn-sm btn-danger btn-sm-custom delete-link-btn" '
html += f'data-link-id="{link.id}" data-link-name="{link.link[:12]}">🗑️</button>'
# Last access
if link.last_access_time:
local_time = localtime(link.last_access_time)
html += f'<span style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px; font-size: 10px; color: #6c757d;">'
html += f'{local_time.strftime("%m-%d %H:%M")}'
html += f'</span>'
else:
html += f'<span style="background: #f8d7da; color: #721c24; padding: 2px 6px; border-radius: 3px; font-size: 10px;">'
html += f'Never'
html += f'</span>'
html += f'</div></div>'
# Add link button
html += f'<div style="text-align: center; margin-top: 12px;">'
html += f'<button type="button" class="btn btn-sm btn-success add-link-btn" '
html += f'data-server-id="{server.id}" data-server-name="{server.name}">'
html += f' Add Link'
html += f'</button>'
html += f'</div>'
html += '</div>' # End server-section
# Add server access section
if unassigned_servers:
html += '<div style="background: #d1ecf1; border-left: 4px solid #bee5eb; padding: 12px; margin-top: 16px; border-radius: 4px;">'
html += '<h5 style="margin: 0 0 8px 0; color: #0c5460; font-size: 13px;"> Available Servers</h5>'
html += '<div style="display: flex; gap: 8px; flex-wrap: wrap;">'
for server in unassigned_servers:
type_icon = '🔵' if server.server_type == 'outline' else '🟢' if server.server_type == 'wireguard' else ''
html += f'<button type="button" class="btn btn-sm btn-outline-info btn-sm-custom add-server-btn" '
html += f'data-server-id="{server.id}" data-server-name="{server.name}">'
html += f'{type_icon} {server.name}'
html += f'</button>'
html += '</div></div>'
html += '</div>' # End user-management-section
return mark_safe(html) return mark_safe(html)
except Exception as e: except Exception as e:
return mark_safe(f'<span style="color: #dc2626;">Error loading statistics: {e}</span>') return mark_safe(f'<span style="color: #dc3545;">Error loading management interface: {e}</span>')
@admin.display(description='Recent Activity') @admin.display(description='Recent Activity')
def recent_activity_display(self, obj): def recent_activity_display(self, obj):
"""Display recent activity in compact admin-friendly format"""
try: try:
from datetime import timedelta from datetime import timedelta
from django.utils import timezone 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( recent_logs = AccessLog.objects.filter(
user=obj.username, user=obj.username,
timestamp__gte=timezone.now() - timedelta(days=7) timestamp__gte=seven_days_ago
).order_by('-timestamp')[:10] ).order_by('-timestamp')[:15] # Limit to 15 most recent
if not recent_logs: if not recent_logs:
return mark_safe('<div style="color: #6b7280; font-style: italic;">No recent activity</div>') 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; padding: 12px; border-radius: 6px; margin: 8px 0;">' html = '<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 0; max-height: 300px; overflow-y: auto;">'
html += '<div style="font-weight: bold; margin-bottom: 8px;">Last 7 days:</div>'
for log in recent_logs: # Header
html += '<div style="background: #e9ecef; padding: 8px 12px; border-bottom: 1px solid #dee2e6; font-weight: 600; font-size: 12px; color: #495057;">'
html += f'📊 Recent Activity ({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) 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': if log.action == 'Success':
color = '#16a34a'
icon = '' icon = ''
status_color = '#28a745'
elif log.action == 'Failed': elif log.action == 'Failed':
color = '#dc2626'
icon = '' icon = ''
status_color = '#dc3545'
else: else:
color = '#6b7280'
icon = '' 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'<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; border-bottom: 1px solid #f1f3f4; background: {bg_color};">'
html += f'<div style="display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid #e5e7eb; font-size: 12px;">' # Left side - server and link info
html += f'<span><span style="color: {color};">{icon}</span> {log.server} / {link_display}</span>' html += f'<div style="display: flex; gap: 8px; align-items: center; flex: 1; min-width: 0;">'
html += f'<span style="color: #6b7280;">{time_str}</span>' html += f'<span style="color: {status_color}; font-size: 14px;">{icon}</span>'
html += f'<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.server}</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 += f'</div></div>'
# Right side - timestamp and status
html += f'<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 += f'</div>'
html += f'</div>'
# 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'<div style="background: #e9ecef; padding: 6px 12px; font-size: 11px; color: #6c757d; text-align: center;">'
html += f'Showing 15 of {total_recent} entries from last 7 days'
html += f'</div>' html += f'</div>'
html += '</div>' html += '</div>'
return mark_safe(html) return mark_safe(html)
except Exception as e: except Exception as e:
return mark_safe(f'<span style="color: #dc2626;">Error loading activity: {e}</span>') return mark_safe(f'<span style="color: #dc3545; font-size: 12px;">Error loading activity: {e}</span>')
@admin.display(description='Allowed servers', ordering='server_count') @admin.display(description='Allowed servers', ordering='server_count')
def server_count(self, obj): def server_count(self, obj):
@@ -798,74 +1136,14 @@ class UserAdmin(admin.ModelAdmin):
return JsonResponse({'error': 'Invalid request method'}, status=405) return JsonResponse({'error': 'Invalid request method'}, status=405)
def change_view(self, request, object_id, form_url='', extra_context=None): def change_view(self, request, object_id, form_url='', extra_context=None):
"""Override change view to add extensive user management data""" """Override change view to add user management data to context"""
extra_context = extra_context or {} extra_context = extra_context or {}
if object_id: if object_id:
try: try:
user = User.objects.get(pk=object_id) 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({ extra_context.update({
'user_object': user, 'user_object': user,
'servers_data': servers_data,
'unassigned_servers': unassigned_servers,
'recent_logs': recent_logs,
'external_address': EXTERNAL_ADDRESS, 'external_address': EXTERNAL_ADDRESS,
}) })

View File

@@ -1,6 +1,9 @@
import logging import logging
import json
import requests import requests
from django.db import models from django.db import models
from django.shortcuts import render, redirect
from django.conf import settings
from .generic import Server from .generic import Server
from urllib3 import PoolManager from urllib3 import PoolManager
from outline_vpn.outline_vpn import OutlineVPN, OutlineServerErrorException from outline_vpn.outline_vpn import OutlineVPN, OutlineServerErrorException
@@ -301,10 +304,51 @@ class OutlineServerAdmin(PolymorphicChildModelAdmin):
'user_count', 'user_count',
'server_status_inline', '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',) list_editable = ('admin_url', 'admin_access_cert', 'client_hostname', 'client_port',)
exclude = ('server_type',) 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') @admin.display(description='Clients', ordering='user_count')
def user_count(self, obj): def user_count(self, obj):
return obj.user_count return obj.user_count
@@ -336,8 +380,396 @@ class OutlineServerAdmin(PolymorphicChildModelAdmin):
server_status_full.short_description = "Server Status" 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): def get_model_perms(self, request):
"""It disables display for sub-model""" """It disables display for sub-model"""
return {} return {}
class Media:
js = ('admin/js/generate_link.js',)
css = {'all': ('admin/css/vpn_admin.css',)}
admin.site.register(OutlineServer, OutlineServerAdmin) admin.site.register(OutlineServer, OutlineServerAdmin)

View File

@@ -0,0 +1,176 @@
{% extends "admin/change_form.html" %}
{% load i18n admin_urls static %}
{% block extrahead %}
{{ block.super }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add JSON Import tab
const tabList = document.getElementById('jazzy-tabs');
const tabContent = document.querySelector('.tab-content');
if (tabList && tabContent) {
// Add new tab
const newTab = document.createElement('li');
newTab.className = 'nav-item';
newTab.innerHTML = `
<a class="nav-link" data-toggle="pill" role="tab" aria-controls="json-import-tab" aria-selected="false" href="#json-import-tab">
📥 JSON Import
</a>
`;
tabList.insertBefore(newTab, tabList.firstChild);
// Add tab content
const newTabContent = document.createElement('div');
newTabContent.id = 'json-import-tab';
newTabContent.className = 'tab-pane fade';
newTabContent.setAttribute('role', 'tabpanel');
newTabContent.setAttribute('aria-labelledby', 'json-import-tab');
newTabContent.innerHTML = `
<div class="card">
<div class="p-5 card-body">
<h4 style="color: #007cba; margin-bottom: 1rem;">📥 Quick Import from JSON</h4>
<p style="font-size: 0.875rem; color: #6c757d; margin-bottom: 1rem;">
Paste the JSON configuration from your Outline server setup to automatically fill the fields:
</p>
<div class="form-group">
<label for="import-json-config">JSON Configuration:</label>
<textarea id="import-json-config" class="form-control" 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;"></textarea>
</div>
<button type="button" id="import-json-btn" class="btn btn-primary">
Import Configuration
</button>
<div style="margin-top: 1rem; padding: 0.75rem; background: #e7f3ff; border-left: 4px solid #007cba; border-radius: 4px;">
<strong>Required fields:</strong>
<ul style="margin: 0.5rem 0; padding-left: 20px;">
<li><code>apiUrl</code> - Server management URL</li>
<li><code>certSha256</code> - Certificate fingerprint</li>
</ul>
<strong>Optional fields:</strong>
<ul style="margin: 0.5rem 0; padding-left: 20px;">
<li><code>serverName</code> - Display name</li>
<li><code>clientHostname</code> - Client hostname</li>
<li><code>clientPort</code> - Client port</li>
<li><code>comment</code> - Description</li>
</ul>
</div>
</div>
</div>
`;
tabContent.insertBefore(newTabContent, tabContent.firstChild);
// Make first tab (JSON Import) active
document.querySelector('#jazzy-tabs .nav-link').classList.remove('active');
newTab.querySelector('.nav-link').classList.add('active');
document.querySelector('.tab-pane.active').classList.remove('active', 'show');
newTabContent.classList.add('active', 'show');
}
// Import functionality
function tryAutoFillFromJson() {
const importJsonTextarea = document.getElementById('import-json-config');
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
alert('✅ Configuration imported successfully! Review the fields and save.');
// Switch to Server Configuration tab
const serverConfigTab = document.querySelector('a[href="#server-configuration-tab"]');
if (serverConfigTab) {
serverConfigTab.click();
}
// Focus on name field
if (nameField) {
setTimeout(() => {
nameField.focus();
nameField.select();
}, 300);
}
} catch (error) {
alert(`Invalid JSON format: ${error.message}`);
}
}
// Wait a bit for DOM to be ready, then add event listeners
setTimeout(() => {
const importBtn = document.getElementById('import-json-btn');
const importTextarea = document.getElementById('import-json-config');
if (importBtn) {
importBtn.addEventListener('click', tryAutoFillFromJson);
}
if (importTextarea) {
importTextarea.addEventListener('paste', function(e) {
setTimeout(() => {
tryAutoFillFromJson();
}, 100);
});
}
}, 500);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "admin/change_form.html" %}
{% load static %}
{% block content_title %}
<h1 class="h4 m-0 pr-3 mr-3 border-right">
{% if original %}
🔵 Outline Server: {{ original.name }}
{% else %}
🔵 Add Outline Server
{% endif %}
</h1>
{% endblock %}
{% block admin_change_form_document_ready %}
{{ block.super }}
<script>
// All JavaScript functionality is now handled by generate_link.js
</script>
{% endblock %}
{% block field_sets %}
{{ block.super }}
{% endblock %}

View File

@@ -11,272 +11,81 @@
</h1> </h1>
{% endblock %} {% 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 extrahead %}
{{ block.super }} {{ block.super }}
<style> <style>
.btn-hover:hover { .user-management-section {
opacity: 0.8; background: #f8f9fa;
transform: translateY(-1px); border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1rem;
margin: 0.5rem 0;
} }
.btn-loading {
opacity: 0.6; .user-management-section h4 {
pointer-events: none; margin: 0 0 0.75rem 0;
color: #495057;
font-size: 1rem;
font-weight: 600;
}
.server-section {
background: #ffffff;
border: 1px solid #e9ecef;
border-radius: 0.25rem;
padding: 1rem;
margin-bottom: 1rem;
}
.link-item {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.25rem;
padding: 0.75rem;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-sm-custom {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 0.2rem;
margin: 0 0.1rem;
}
.readonly .user-management-section {
border: none;
background: transparent;
padding: 0;
} }
</style> </style>
{% endblock %} {% endblock %}
{% block admin_change_form_document_ready %} {% block admin_change_form_document_ready %}
{{ block.super }} {{ block.super }}
{% if original %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const userId = {{ original.id|default:"null" }}; const userId = {{ original.id }};
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
// Show success/error messages // Show success/error messages in Django admin style
function showMessage(message, type = 'info') { function showMessage(message, type = 'success') {
const alertClass = type === 'error' ? 'alert-danger' : 'alert-success'; const messageClass = type === 'error' ? 'error' : 'success';
const alertHtml = ` const messageHtml = `
<div class="alert ${alertClass} alert-dismissible fade show" role="alert" style="margin: 16px 0;"> <div class="alert alert-${messageClass} alert-dismissible" style="margin: 1rem 0;">
${message} ${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button> <button type="button" class="close" aria-label="Close" onclick="this.parentElement.remove()">
<span aria-hidden="true">&times;</span>
</button>
</div> </div>
`; `;
// Find a good place to insert the message const target = document.querySelector('.card-body') || document.querySelector('.content');
const target = document.querySelector('.user-access-management') || document.querySelector('.content');
if (target) { if (target) {
target.insertAdjacentHTML('afterbegin', alertHtml); target.insertAdjacentHTML('afterbegin', messageHtml);
// Auto-remove after 5 seconds
setTimeout(() => { setTimeout(() => {
const alert = target.querySelector('.alert'); const alert = target.querySelector('.alert');
if (alert) alert.remove(); if (alert) alert.remove();
@@ -291,11 +100,11 @@
const serverName = this.dataset.serverName; const serverName = this.dataset.serverName;
const comment = prompt(`Add comment for new link on ${serverName} (optional):`, ''); const comment = prompt(`Add comment for new link on ${serverName} (optional):`, '');
if (comment === null) return; // User cancelled if (comment === null) return;
const originalText = this.textContent; const originalText = this.textContent;
this.textContent = '⏳ Adding...'; this.textContent = '⏳ Adding...';
this.classList.add('btn-loading'); this.disabled = true;
try { try {
const response = await fetch(`/admin/vpn/user/${userId}/add-link/`, { const response = await fetch(`/admin/vpn/user/${userId}/add-link/`, {
@@ -310,9 +119,8 @@
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
showMessage(`✅ New link created successfully: ${data.link}`, 'success'); showMessage(`✅ New link created successfully: ${data.link}`);
// Refresh the page to show the new link setTimeout(() => window.location.reload(), 1000);
window.location.reload();
} else { } else {
showMessage(`❌ Error: ${data.error}`, 'error'); showMessage(`❌ Error: ${data.error}`, 'error');
} }
@@ -320,7 +128,7 @@
showMessage(`❌ Network error: ${error.message}`, 'error'); showMessage(`❌ Network error: ${error.message}`, 'error');
} finally { } finally {
this.textContent = originalText; this.textContent = originalText;
this.classList.remove('btn-loading'); this.disabled = false;
} }
}); });
}); });
@@ -337,7 +145,7 @@
const originalText = this.textContent; const originalText = this.textContent;
this.textContent = '⏳ Deleting...'; this.textContent = '⏳ Deleting...';
this.classList.add('btn-loading'); this.disabled = true;
try { try {
const response = await fetch(`/admin/vpn/user/${userId}/delete-link/${linkId}/`, { const response = await fetch(`/admin/vpn/user/${userId}/delete-link/${linkId}/`, {
@@ -351,9 +159,8 @@
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
showMessage(`✅ Link ${linkName} deleted successfully`, 'success'); showMessage(`✅ Link ${linkName} deleted successfully`);
// Remove the link element from DOM this.closest('.link-item')?.remove();
this.closest('.link-container')?.remove() || window.location.reload();
} else { } else {
showMessage(`❌ Error: ${data.error}`, 'error'); showMessage(`❌ Error: ${data.error}`, 'error');
} }
@@ -361,7 +168,7 @@
showMessage(`❌ Network error: ${error.message}`, 'error'); showMessage(`❌ Network error: ${error.message}`, 'error');
} finally { } finally {
this.textContent = originalText; this.textContent = originalText;
this.classList.remove('btn-loading'); this.disabled = false;
} }
}); });
}); });
@@ -378,7 +185,7 @@
const originalText = this.textContent; const originalText = this.textContent;
this.textContent = '⏳ Adding...'; this.textContent = '⏳ Adding...';
this.classList.add('btn-loading'); this.disabled = true;
try { try {
const response = await fetch(`/admin/vpn/user/${userId}/add-server-access/`, { const response = await fetch(`/admin/vpn/user/${userId}/add-server-access/`, {
@@ -393,9 +200,8 @@
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
showMessage(`✅ Access to ${serverName} added successfully`, 'success'); showMessage(`✅ Access to ${serverName} added successfully`);
// Refresh the page to show the new server section setTimeout(() => window.location.reload(), 1000);
window.location.reload();
} else { } else {
showMessage(`❌ Error: ${data.error}`, 'error'); showMessage(`❌ Error: ${data.error}`, 'error');
} }
@@ -403,10 +209,11 @@
showMessage(`❌ Network error: ${error.message}`, 'error'); showMessage(`❌ Network error: ${error.message}`, 'error');
} finally { } finally {
this.textContent = originalText; this.textContent = originalText;
this.classList.remove('btn-loading'); this.disabled = false;
} }
}); });
}); });
}); });
</script> </script>
{% endif %}
{% endblock %} {% endblock %}