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:
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||||
|
@@ -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">×</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
482
vpn/admin.py
482
vpn/admin.py
@@ -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>'
|
||||||
return mark_safe(html)
|
|
||||||
|
# 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:
|
except Exception as e:
|
||||||
return mark_safe(f'<span style="color: #dc2626;">Error loading statistics: {e}</span>')
|
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)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@@ -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)
|
||||||
|
176
vpn/templates/admin/vpn/outlineserver/add_form.html.backup
Normal file
176
vpn/templates/admin/vpn/outlineserver/add_form.html.backup
Normal 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 %}
|
23
vpn/templates/admin/vpn/outlineserver/change_form.html
Normal file
23
vpn/templates/admin/vpn/outlineserver/change_form.html
Normal 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 %}
|
@@ -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">×</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 %}
|
||||||
|
Reference in New Issue
Block a user