feat: ssh auth, protocol management, and cleanup

This commit is contained in:
infosave2007
2026-01-23 17:55:40 +03:00
parent 4995147bad
commit ea82b78a7d
70 changed files with 16225 additions and 986 deletions
+244
View File
@@ -0,0 +1,244 @@
{% extends "layout.twig" %}
{% block title %}{{ t('ai.generation_preview') }} - {{ parent() }}{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900">{{ t('ai.generation_preview') }}</h1>
<p class="mt-2 text-gray-600">{{ t('ai.generation_preview_description') }}</p>
</div>
<div class="flex space-x-3">
<a href="/settings/protocols" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
{{ t('protocols.back_to_protocols') }}
</a>
<button id="apply-script-btn" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
{{ t('ai.apply_to_protocol') }}
</button>
</div>
</div>
</div>
<!-- Generation Info -->
<div class="bg-white shadow rounded-lg mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">{{ t('ai.generation_details') }}</h2>
</div>
<div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700">{{ t('ai.model_used') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ generation.model_used }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">{{ t('ai.generated_at') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ generation.created_at|date('Y-m-d H:i:s') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">{{ t('protocols.ubuntu_compatible') }}</label>
<div class="mt-1">
{% if generation.ubuntu_compatible %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
{{ t('common.compatible') }}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
{{ t('common.not_compatible') }}
</span>
{% endif %}
</div>
</div>
{% if generation.protocol_name %}
<div>
<label class="block text-sm font-medium text-gray-700">{{ t('ai.associated_protocol') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ generation.protocol_name }}</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Prompt -->
<div class="bg-white shadow rounded-lg mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">{{ t('ai.user_prompt') }}</h2>
</div>
<div class="px-6 py-4">
<p class="text-sm text-gray-900 whitespace-pre-wrap">{{ generation.prompt }}</p>
</div>
</div>
<!-- Generated Script -->
<div class="bg-white shadow rounded-lg mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h2 class="text-lg font-medium text-gray-900">{{ t('ai.generated_installation_script') }}</h2>
<button id="copy-script-btn" class="inline-flex items-center px-3 py-1 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{{ t('ai.copy_script') }}
</button>
</div>
</div>
<div class="px-6 py-4">
<div class="bg-gray-900 text-green-400 p-4 rounded-md overflow-x-auto">
<pre id="script-content" class="text-sm whitespace-pre-wrap">{{ script }}</pre>
</div>
</div>
</div>
<!-- AI Suggestions -->
{% if suggestions %}
<div class="bg-white shadow rounded-lg mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">{{ t('ai.suggestions') }}</h2>
</div>
<div class="px-6 py-4">
<ul class="space-y-2">
{% for suggestion in suggestions %}
<li class="flex items-start">
<svg class="w-5 h-5 text-blue-500 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<span class="text-sm text-gray-900">{{ suggestion }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<!-- Actions -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">{{ t('common.actions') }}</h2>
</div>
<div class="px-6 py-4">
<div class="flex flex-wrap gap-3">
<button id="download-script-btn" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ t('ai.download_script') }}
</button>
{% if generation.protocol_id %}
<button id="view-protocol-btn" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ t('protocols.view_protocol') }}
</button>
{% endif %}
<button id="regenerate-btn" class="inline-flex items-center px-4 py-2 border border-purple-300 rounded-md shadow-sm text-sm font-medium text-purple-700 bg-white hover:bg-purple-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
{{ t('ai.regenerate') }}
</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const scriptContent = document.getElementById('script-content').textContent;
const generationId = {{ generation.id }};
const protocolId = {{ generation.protocol_id ?: 'null' }};
// Copy script
document.getElementById('copy-script-btn').addEventListener('click', function() {
const textarea = document.createElement('textarea');
textarea.value = scriptContent;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
// Show feedback
const originalText = this.innerHTML;
this.innerHTML = '<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>{{ t('common.copied') }}';
setTimeout(() => {
this.innerHTML = originalText;
}, 2000);
});
// Download script
document.getElementById('download-script-btn').addEventListener('click', function() {
const blob = new Blob([scriptContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `install-${generationId}.sh`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
// Apply to protocol
document.getElementById('apply-script-btn').addEventListener('click', function() {
if (!protocolId) {
alert('{{ t('ai.no_associated_protocol') }}');
return;
}
if (confirm('{{ t('ai.confirm_apply_script_to_protocol') }}')) {
fetch(`/api/ai/generations/${generationId}/apply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert('{{ t('ai.script_applied_successfully') }}');
window.location.href = `/settings/protocols/${protocolId}/edit`;
} else {
alert('{{ t('ai.error_applying_script') }}: ' + result.error);
}
})
.catch(error => {
alert('{{ t('ai.error_applying_script') }}: ' + error.message);
});
}
});
// View protocol
document.getElementById('view-protocol-btn').addEventListener('click', function() {
if (protocolId) {
window.location.href = `/settings/protocols/${protocolId}/edit`;
}
});
// Regenerate
document.getElementById('regenerate-btn').addEventListener('click', function() {
if (confirm('{{ t('ai.confirm_regenerate_script') }}')) {
// Go back to protocols page with AI assistant open
window.location.href = '/settings/protocols';
}
});
});
</script>
{% endblock %}
+8
View File
@@ -18,6 +18,7 @@
{% endif %}
</dd>
</div>
<div><dt class="text-sm text-gray-600">Логин</dt><dd>{{ client.name|default('') }}</dd></div>
<div><dt class="text-sm text-gray-600">{{ t('common.created') }}</dt><dd>{{ client.created_at }}</dd></div>
</dl>
<div class="flex gap-2">
@@ -150,6 +151,13 @@
<p class="text-sm text-gray-600 mt-2">Scan with Amnezia VPN app</p>
</div>
{% endif %}
{% if protocol_output and client.show_text_content %}
<div class="bg-white rounded shadow p-6 mt-6">
<h3 class="font-bold mb-4">{{ t('clients.connection_instructions') }}</h3>
<pre class="mb-0" style="white-space: pre-wrap;">{{ protocol_output }}</pre>
</div>
{% endif %}
</div>
<script>
+182 -33
View File
@@ -3,43 +3,113 @@
{% block content %}
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold mb-8"><i class="fas fa-plus-circle text-purple-600"></i> Add New Server</h1>
{% if error %}<div class="mb-4 bg-red-50 border border-red-400 text-red-700 px-4 py-3 rounded">{{ error }}</div>{% endif %}
{% if error %}
<div class="mb-4 bg-red-50 border border-red-400 text-red-700 px-4 py-3 rounded">{{ error }}</div>
{% endif %}
<form method="POST" enctype="multipart/form-data" class="bg-white shadow rounded-lg p-6 space-y-6">
<div><label class="block text-sm font-medium text-gray-700">Server Name</label><input name="name" required class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md" placeholder="US Server 1"></div>
<div><label class="block text-sm font-medium text-gray-700">Host IP/Domain</label><input name="host" required class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md" placeholder="123.456.789.0"></div>
<div><label class="block text-sm font-medium text-gray-700">SSH Port</label><input name="port" type="number" value="22" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"></div>
<div><label class="block text-sm font-medium text-gray-700">SSH Username</label><input name="username" value="root" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"></div>
<div><label class="block text-sm font-medium text-gray-700">SSH Password</label><input name="password" type="password" required class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md"></div>
<!-- Import from existing panel -->
<div class="border-t pt-6">
<div class="flex items-center mb-4">
<input type="checkbox" id="enableImport" name="enable_import" class="h-4 w-4 text-purple-600 rounded" onchange="toggleImportFields()">
<label for="enableImport" class="ml-2 text-sm font-medium text-gray-700">
{{ t('servers.import_from_panel') }}
<div>
<label class="block text-sm font-medium text-gray-700">{{ t('servers.creation_mode') }}</label>
<div class="mt-2 flex items-center gap-6">
<label class="inline-flex items-center">
<input type="radio" name="creation_mode" value="manual" class="text-purple-600" {% if selected_mode|default('manual') == 'manual' %}checked{% endif %}>
<span class="ml-2 text-sm text-gray-700">{{ t('servers.creation_mode_manual') }}</span>
</label>
<label class="inline-flex items-center">
<input type="radio" name="creation_mode" value="backup" class="text-purple-600" {% if selected_mode == 'backup' %}checked{% endif %}>
<span class="ml-2 text-sm text-gray-700">{{ t('servers.creation_mode_backup') }}</span>
</label>
</div>
<div id="importFields" style="display: none;" class="space-y-4 pl-6 border-l-2 border-purple-200">
</div>
<div id="manualSection" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Server Name</label>
<input name="name" data-field-manual required class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md" placeholder="US Server 1" value="{{ form_data.name ?? '' }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Installation Protocol</label>
{% set selectedProtocol = form_data.install_protocol ?? default_protocol %}
<select name="install_protocol" data-field-manual class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md">
{% for protocol in protocols %}
<option value="{{ protocol.slug }}" {% if protocol.slug == selectedProtocol %}selected{% endif %}>{{ protocol.name }}</option>
{% endfor %}
</select>
<p id="protocolDescription" class="mt-1 text-xs text-gray-500"></p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Host IP/Domain</label>
<input name="host" data-field-manual required class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md" placeholder="123.456.789.0" value="{{ form_data.host ?? '' }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">SSH Port</label>
<input name="port" data-field-manual type="number" value="{{ form_data.port ?? 22 }}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-sm font-medium text-gray-700">SSH Username</label>
<input name="username" data-field-manual value="{{ form_data.username ?? 'root' }}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">{{ t('servers.select_panel_type') }}</label>
<select name="panel_type" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">-- {{ t('servers.select_panel_type') }} --</option>
<option value="wg-easy">{{ t('servers.panel_type_wgeasy') }}</option>
<option value="3x-ui">{{ t('servers.panel_type_3xui') }}</option>
</select>
<label class="block text-sm font-medium text-gray-700">Authentication Method</label>
<div class="mt-2 flex items-center gap-6">
<label class="inline-flex items-center">
<input type="radio" name="auth_method" value="password" class="text-purple-600" checked onchange="toggleAuthMethod()">
<span class="ml-2 text-sm text-gray-700">Password</span>
</label>
<label class="inline-flex items-center">
<input type="radio" name="auth_method" value="ssh_key" class="text-purple-600" onchange="toggleAuthMethod()">
<span class="ml-2 text-sm text-gray-700">SSH Key</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">{{ t('servers.upload_backup_file') }}</label>
<input type="file" name="backup_file" accept=".json" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md">
<p class="mt-1 text-xs text-gray-500">
wg-easy: db.json | 3x-ui: export.json
</p>
<div id="authPassword">
<label class="block text-sm font-medium text-gray-700">SSH Password</label>
<input name="password" data-field-manual type="password" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div id="authSshKey" style="display: none;">
<label class="block text-sm font-medium text-gray-700">SSH Private Key</label>
<textarea name="ssh_key" data-field-manual rows="6" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md font-mono text-xs" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----..."></textarea>
<p class="mt-1 text-xs text-gray-500">Paste your private key (PEM or OpenSSH format)</p>
</div>
</div>
</div>
<div id="backupSection" class="space-y-6" style="display: none;">
<input type="hidden" name="backup_token" value="{{ form_data.backup_token ?? '' }}">
<div>
<label class="block text-sm font-medium text-gray-700">{{ t('servers.backup_upload_type') }}</label>
<select name="backup_upload_type" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md" data-field-backup>
<option value="auto" {% if form_data.backup_upload_type|default('auto') == 'auto' %}selected{% endif %}>{{ t('servers.backup_type_auto') }}</option>
<option value="amnezia_app" {% if form_data.backup_upload_type|default('auto') == 'amnezia_app' %}selected{% endif %}>{{ t('servers.backup_type_amnezia') }}</option>
<option value="panel_backup" {% if form_data.backup_upload_type|default('auto') == 'panel_backup' %}selected{% endif %}>{{ t('servers.backup_type_panel') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">{{ t('servers.upload_backup_file') }}</label>
<input type="file" name="backup_upload" accept=".backup,.json" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md" data-field-backup>
<p class="mt-1 text-xs text-gray-500">{{ t('servers.backup_upload_hint') }}</p>
</div>
{% if form_data.uploaded_servers is defined and form_data.uploaded_servers %}
<div class="border-t pt-6 space-y-2">
<label class="block text-sm font-medium text-gray-700">{{ t('servers.backup_server_entry') }}</label>
<select name="backup_server_index" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md" data-field-backup>
{% for server in form_data.uploaded_servers %}
{% set option_host = server.host is defined and server.host is not empty ? server.host : t('common.na') %}
{% set option_clients = server.client_count is defined ? server.client_count : t('common.na') %}
<option value="{{ server.index }}" {% if form_data.backup_server_index|default('') == (server.index ~ '') %}selected{% endif %}>
{{ server.label }}{{ t('servers.backup_summary_host') }}: {{ option_host }}, {{ t('servers.backup_summary_clients') }}: {{ option_clients }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
<button type="submit" class="w-full gradient-bg text-white py-2 px-4 rounded-md hover:opacity-90">
<i class="fas fa-save mr-2"></i>Create Server
</button>
@@ -47,11 +117,90 @@
</div>
<script>
function toggleImportFields() {
const checkbox = document.getElementById('enableImport');
const fields = document.getElementById('importFields');
fields.style.display = checkbox.checked ? 'block' : 'none';
const modeRadios = Array.prototype.slice.call(document.querySelectorAll('input[name="creation_mode"]'));
const manualSection = document.getElementById('manualSection');
const backupSection = document.getElementById('backupSection');
const manualFields = Array.prototype.slice.call(document.querySelectorAll('[data-field-manual]'));
const backupFields = Array.prototype.slice.call(document.querySelectorAll('[data-field-backup]'));
const initialMode = {{ selected_mode|default('manual')|json_encode|raw }};
function setFieldsState(mode) {
manualFields.forEach(el => {
if (!el) return;
el.disabled = mode !== 'manual';
});
backupFields.forEach(el => {
if (!el) return;
el.disabled = mode !== 'backup';
});
}
function switchMode() {
const selectedRadio = document.querySelector('input[name="creation_mode"]:checked');
const mode = selectedRadio ? selectedRadio.value : 'manual';
if (manualSection) {
manualSection.style.display = mode === 'manual' ? 'block' : 'none';
}
if (backupSection) {
backupSection.style.display = mode === 'backup' ? 'block' : 'none';
}
setFieldsState(mode);
}
modeRadios.forEach(function (radio) {
radio.addEventListener('change', switchMode);
});
const initialModeRadio = document.querySelector(`input[name="creation_mode"][value="${initialMode}"]`);
if (initialModeRadio) {
initialModeRadio.checked = true;
}
switchMode();
{% set protocol_map = {} %}
{% for protocol in protocols %}
{% set protocol_map = protocol_map | merge({ (protocol.slug): (protocol.description ?? '') }) %}
{% endfor %}
const protocolDescriptions = {{ protocol_map | json_encode | raw }};
const protocolDescriptionEl = document.getElementById('protocolDescription');
const protocolSelect = document.querySelector('select[name="install_protocol"]');
function updateProtocolDescription() {
if (!protocolSelect || !protocolDescriptionEl) return;
const description = protocolDescriptions[protocolSelect.value] || '';
protocolDescriptionEl.textContent = description;
protocolDescriptionEl.style.display = description ? 'block' : 'none';
}
function toggleAuthMethod() {
const method = document.querySelector('input[name="auth_method"]:checked').value;
const passwordSection = document.getElementById('authPassword');
const keySection = document.getElementById('authSshKey');
const passwordInput = passwordSection.querySelector('input');
const keyInput = keySection.querySelector('textarea');
if (method === 'password') {
passwordSection.style.display = 'block';
keySection.style.display = 'none';
passwordInput.required = true;
keyInput.required = false;
keyInput.value = ''; // Clear key if switching back to password to avoid ambiguity
} else {
passwordSection.style.display = 'none';
keySection.style.display = 'block';
passwordInput.required = false;
passwordInput.value = ''; // Clear password
keyInput.required = true;
}
}
if (protocolSelect) {
protocolSelect.addEventListener('change', updateProtocolDescription);
updateProtocolDescription();
}
// Initialize auth method state
toggleAuthMethod();
</script>
{% endblock %}
+113 -32
View File
@@ -2,55 +2,136 @@
{% block title %}Deploy {{ server.name }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-6">Deploying: {{ server.name }}</h1>
<h1 class="text-2xl font-bold mb-1">Deploying: {{ server.name }}</h1>
<p class="text-sm text-gray-400 mb-6">Protocol: {{ server.install_protocol ?? 'amnezia-wg' }}</p>
<div id="deployLog" class="bg-gray-900 text-green-400 p-4 rounded font-mono text-sm h-96 overflow-y-auto mb-4">
<div>Ready to deploy...</div>
</div>
<div id="deployActions" class="flex gap-3 mb-4 hidden"></div>
<button id="deployBtn" onclick="deploy()" class="gradient-bg text-white px-6 py-2 rounded hover:opacity-90 transition-all disabled:opacity-50 disabled:cursor-not-allowed">
<span id="btnText">Start Deployment</span>
<i id="btnSpinner" class="fas fa-spinner fa-spin ml-2 hidden"></i>
</button>
</div>
<script>
function deploy() {
let pendingDecisionToken = null;
function setButtonState(isProcessing, label) {
const btn = document.getElementById('deployBtn');
const btnText = document.getElementById('btnText');
const btnSpinner = document.getElementById('btnSpinner');
btn.disabled = isProcessing;
btnText.textContent = label;
if (isProcessing) {
btnSpinner.classList.remove('hidden');
} else {
btnSpinner.classList.add('hidden');
}
}
function appendLog(message, cssClass) {
const log = document.getElementById('deployLog');
// Disable button and show spinner
btn.disabled = true;
btnText.textContent = 'Deploying...';
btnSpinner.classList.remove('hidden');
log.innerHTML = '<div>📡 Connecting to server...</div>';
log.innerHTML += '<div>🔧 Installing Docker...</div>';
log.innerHTML += '<div>📦 Building container...</div>';
log.innerHTML += '<div>🔐 Generating keys...</div>';
log.innerHTML += '<div>⚙️ Configuring WireGuard...</div>';
fetch('/servers/{{ server.id }}/deploy', {method: 'POST'})
.then(r => r.json())
.then(d => {
if (d.success) {
log.innerHTML += '<div class="text-green-500 font-bold">✅ Deployment successful!</div>';
log.innerHTML += '<div class="text-yellow-300">🔌 VPN Port: ' + d.vpn_port + '</div>';
log.innerHTML += '<div class="text-yellow-300">🔑 Public Key: ' + d.public_key.substring(0, 40) + '...</div>';
btnText.textContent = 'Redirecting...';
btnSpinner.classList.add('hidden');
const line = document.createElement('div');
if (cssClass) {
line.className = cssClass;
}
line.innerHTML = message;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
function hideActions() {
const actions = document.getElementById('deployActions');
actions.classList.add('hidden');
actions.innerHTML = '';
}
function showActions(options) {
const actions = document.getElementById('deployActions');
actions.innerHTML = '';
Object.keys(options || {}).forEach(key => {
const option = options[key];
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = option.label || key;
btn.className = 'px-4 py-2 rounded bg-white text-gray-800 border border-gray-200 shadow-sm hover:bg-gray-50 transition';
btn.onclick = function () {
hideActions();
deploy(option.mode || key);
};
actions.appendChild(btn);
});
if (actions.childElementCount > 0) {
actions.classList.remove('hidden');
}
}
function deploy(mode) {
const payload = {};
if (mode) {
payload.install_mode = mode;
}
if (pendingDecisionToken) {
payload.decision_token = pendingDecisionToken;
}
if (!mode) {
pendingDecisionToken = null;
document.getElementById('deployLog').innerHTML = '';
appendLog('📡 Connecting to server...');
appendLog('🔧 Preparing environment...');
}
hideActions();
setButtonState(true, mode ? 'Processing...' : 'Deploying...');
fetch('/servers/{{ server.id }}/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(async response => {
const data = await response.json().catch(() => ({ success: false, error: 'Invalid server response' }));
if (!response.ok && !data.requires_action) {
throw new Error(data.error || ('HTTP ' + response.status));
}
return data;
})
.then(data => {
if (data.requires_action) {
pendingDecisionToken = data.decision_token || null;
const details = data.details || {};
appendLog('⚠️ ' + (details.message || 'Existing configuration detected'), 'text-yellow-300');
if (details.details && details.details.summary) {
appendLog(details.details.summary, 'text-yellow-200');
} else if (details.details) {
appendLog(JSON.stringify(details.details), 'text-yellow-200 text-xs');
}
showActions(data.options);
setButtonState(false, 'Select action');
return;
}
if (data.success) {
pendingDecisionToken = null;
hideActions();
appendLog('✅ Deployment successful!', 'text-green-500 font-bold');
if (data.vpn_port) {
appendLog('🔌 VPN Port: ' + data.vpn_port, 'text-yellow-300');
}
if (data.public_key) {
appendLog('🔑 Public Key: ' + data.public_key.substring(0, 40) + '...', 'text-yellow-300');
}
setButtonState(true, 'Redirecting...');
setTimeout(() => window.location.href = '/servers/{{ server.id }}', 2000);
} else {
log.innerHTML += '<div class="text-red-500 font-bold">❌ Error: ' + (d.error || 'Unknown error') + '</div>';
btn.disabled = false;
btnText.textContent = 'Retry Deployment';
btnSpinner.classList.add('hidden');
appendLog('❌ ' + (data.error || 'Unknown error'), 'text-red-500 font-bold');
setButtonState(false, 'Retry Deployment');
}
})
.catch(e => {
log.innerHTML += '<div class="text-red-500 font-bold">❌ Network error: ' + e.message + '</div>';
btn.disabled = false;
btnText.textContent = 'Retry Deployment';
btnSpinner.classList.add('hidden');
.catch(error => {
appendLog('❌ Network error: ' + error.message, 'text-red-500 font-bold');
setButtonState(false, 'Retry Deployment');
});
}
</script>
+203 -10
View File
@@ -60,8 +60,50 @@
<div><dt class="text-sm text-gray-600">{{ t('common.status') }}</dt><dd><span class="px-2 py-1 bg-green-100 text-green-800 rounded text-sm">{{ server.status }}</span></dd></div>
<div><dt class="text-sm text-gray-600">VPN Port</dt><dd>{{ server.vpn_port }}</dd></div>
<div><dt class="text-sm text-gray-600">Subnet</dt><dd>{{ server.vpn_subnet }}</dd></div>
</dl>
<div class="mt-4 flex items-center gap-2">
<button type="button" id="uninstallAllBtn" class="px-3 py-1 bg-gray-600 text-white rounded text-sm">Удалить все протоколы</button>
<span id="uninstallMsg" class="ml-3 text-sm text-gray-600"></span>
</div>
<div class="mt-4">
<label class="block text-sm text-gray-600 mb-1">Добавить протокол</label>
<div class="flex items-center gap-2">
<select id="availableProtocolSelect" class="px-3 py-2 border rounded">
{% for p in available_protocols %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
<button id="activateProtocolBtn" class="px-3 py-1 bg-green-600 text-white rounded text-sm">Установить</button>
<span id="activateMsg" class="ml-3 text-sm text-gray-600"></span>
</div>
</div>
<!-- Установка протоколов выполняется только через Настройки -->
<div class="mt-4">
<label class="block text-sm text-gray-600 mb-1">Установленные протоколы</label>
<div class="space-y-2">
{% for sp in server_protocols %}
<div class="border rounded px-3 py-2">
<div class="flex items-center justify-between">
<div class="text-sm font-medium">{{ sp.name }} <span class="text-gray-500">({{ sp.slug }})</span></div>
<button type="button" class="px-3 py-1 bg-red-600 text-white rounded text-sm btn-uninstall-sp" data-slug="{{ sp.slug }}">Удалить</button>
</div>
<div class="mt-1 text-xs text-gray-600">
{% if sp.server_host %}<span>Host: {{ sp.server_host }}</span>{% endif %}
{% if sp.server_port %}<span class="ml-2">Port: {{ sp.server_port }}</span>{% endif %}
</div>
</div>
{% else %}
<div class="text-sm text-gray-500">Нет установленных протоколов</div>
{% endfor %}
</div>
<div id="uninstallSpMsg" class="mt-2 text-sm text-gray-600"></div>
</div>
{% if server.status == 'active' %}
<div class="metric-mini" id="serverMetrics">
<div class="metric-row">
@@ -96,7 +138,17 @@
<form method="POST" action="/servers/{{ server.id }}/clients/create" class="space-y-3" id="createClientForm">
<div>
<input name="name" placeholder="{{ t('clients.name') }}" required class="w-full px-3 py-2 border rounded" id="clientName">
<p class="text-xs text-gray-500 mt-1">Spaces will be replaced with underscore. All characters allowed including Cyrillic.</p>
</div>
<div>
<input name="login" placeholder="Логин (уникально на сервере, пусто — из имени)" class="w-full px-3 py-2 border rounded" id="clientLogin">
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">{{ t('ai.protocol_type') }}</label>
<select name="protocol_id" class="w-full px-3 py-2 border rounded">
{% for sp in server_protocols %}
<option value="{{ sp.protocol_id }}">{{ sp.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm text-gray-600 mb-1">{{ t('clients.expiration') }}</label>
@@ -136,6 +188,31 @@
</form>
</div>
</div>
<div class="bg-white rounded shadow p-6 mb-8">
<h3 class="font-bold mb-4 flex items-center gap-2">
<i class="fas fa-file-import text-purple-500"></i>
{{ t('servers.config_import_title') }}
</h3>
<p class="text-sm text-gray-600 mb-4">{{ t('servers.config_import_hint') }}</p>
<form method="POST" action="/servers/{{ server.id }}/config/import" enctype="multipart/form-data" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('servers.config_import_type_label') }}</label>
<select name="import_type" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="panel_backup">{{ t('servers.config_import_type_panel') }}</option>
<option value="amnezia_app">{{ t('servers.config_import_type_amnezia') }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('servers.config_import_file_label') }}</label>
<input type="file" name="config_file" accept=".json,.backup" required class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md">
<p class="text-xs text-gray-500 mt-1">{{ t('servers.config_import_file_hint') }}</p>
</div>
<button type="submit" class="gradient-bg text-white px-4 py-2 rounded">
<i class="fas fa-upload mr-2"></i>{{ t('servers.config_import_submit') }}
</button>
</form>
</div>
<!-- Backup Section -->
<div class="bg-white rounded shadow mb-8">
@@ -154,15 +231,28 @@
<div class="bg-white rounded shadow">
<div class="px-6 py-4 border-b flex justify-between items-center">
<h3 class="font-bold">{{ t('clients.title') }} ({{ clients|length }})</h3>
<button onclick="syncAllStats({{ server.id }})" class="text-purple-600 hover:text-purple-800 text-sm">
<i class="fas fa-sync-alt"></i> {{ t('clients.sync_stats') }}
</button>
<div class="flex items-center gap-3">
<form method="GET" action="/servers/{{ server.id }}" class="flex items-center gap-2">
<label class="text-sm text-gray-600">{{ t('ai.protocol_type') }}</label>
<select name="protocol_id" class="px-3 py-2 border rounded" onchange="this.form.submit()">
<option value="">Все</option>
{% for sp in server_protocols %}
<option value="{{ sp.protocol_id }}" {% if selected_protocol_id == sp.protocol_id %}selected{% endif %}>{{ sp.name }}</option>
{% endfor %}
</select>
</form>
<button onclick="syncAllStats({{ server.id }})" class="text-purple-600 hover:text-purple-800 text-sm">
<i class="fas fa-sync-alt"></i> {{ t('clients.sync_stats') }}
</button>
</div>
</div>
{% if clients|length > 0 %}
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Логин</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('ai.protocol_type') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.ip') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.status') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('clients.expiration') }}</th>
@@ -177,6 +267,14 @@
{% for client in clients %}
<tr class="border-t">
<td class="px-6 py-4">{{ client.name }}</td>
<td class="px-6 py-4">{{ client.login|default('-') }}</td>
<td class="px-6 py-4">
{% if client.protocol_name %}
<span class="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">{{ client.protocol_name }}</span>
{% else %}
<span class="text-gray-400">-</span>
{% endif %}
</td>
<td class="px-6 py-4">{{ client.client_ip }}</td>
<td class="px-6 py-4">
{% if client.status == 'active' %}
@@ -286,6 +384,102 @@ function toggleExpirationInput() {
}
}
document.addEventListener('DOMContentLoaded', function() {
const uninstallAllBtn = document.getElementById('uninstallAllBtn');
const msg = document.getElementById('uninstallMsg');
if (uninstallAllBtn) {
uninstallAllBtn.addEventListener('click', async function(e) {
console.log('uninstallAllBtn clicked');
e.preventDefault();
e.stopPropagation();
if (!confirm('Удалить все Amnezia-контейнеры на сервере?')) {
console.log('User canceled');
return;
}
console.log('Starting uninstall all...');
uninstallAllBtn.disabled = true;
msg.innerHTML = '<i class="fas fa-circle-notch fa-spin text-red-600 mr-2"></i><span class="text-gray-700">Удаление всех контейнеров...</span>';
try {
const res = await fetch(`/servers/{{ server.id }}/protocols/uninstall-all`, { method: 'POST', credentials: 'same-origin' });
const data = await res.json();
console.log('Response:', data);
if (data.success) {
msg.textContent = data.message || 'Успешно';
setTimeout(() => location.reload(), 1200);
} else {
msg.textContent = data.error || 'Ошибка';
}
} catch (e) {
console.error('Error:', e);
msg.textContent = e.message;
}
uninstallAllBtn.disabled = false;
});
}
const activateBtn = document.getElementById('activateProtocolBtn');
if (activateBtn) {
activateBtn.addEventListener('click', async function() {
const select = document.getElementById('availableProtocolSelect');
const msg2 = document.getElementById('activateMsg');
msg2.textContent = '';
const pid = select ? select.value : '';
if (!pid) { msg2.textContent = 'Нет доступных протоколов'; return; }
activateBtn.disabled = true;
msg2.innerHTML = '<i class="fas fa-circle-notch fa-spin text-blue-600 mr-2"></i><span class="text-gray-700">Установка протокола...</span>';
try {
const res = await fetch('/servers/{{ server.id }}/protocols/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'protocol_id=' + encodeURIComponent(pid)
});
let data;
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) { data = await res.json(); } else { data = { error: await res.text() }; }
if (res.ok && data && data.success !== false && !data.error) {
msg2.textContent = 'Готово';
setTimeout(() => location.reload(), 1000);
} else {
msg2.textContent = (data && data.error) ? data.error : ('Ошибка установки (' + res.status + ')');
}
} catch (e) {
msg2.textContent = e.message || 'Ошибка связи';
}
activateBtn.disabled = false;
});
}
document.querySelectorAll('.btn-uninstall-sp').forEach(btn => {
btn.addEventListener('click', async function(e) {
e.preventDefault();
if (!confirm('Удалить протокол и всех его клиентов?')) return;
const slug = btn.getAttribute('data-slug');
const m = document.getElementById('uninstallSpMsg');
m.textContent = '';
btn.disabled = true;
m.innerHTML = '<i class="fas fa-circle-notch fa-spin text-red-600 mr-2"></i><span class="text-gray-700">Удаление протокола...</span>';
try {
const resp = await fetch('/servers/{{ server.id }}/protocols/' + encodeURIComponent(slug) + '/uninstall', { method: 'POST', credentials: 'same-origin' });
let data;
const ct = resp.headers.get('content-type') || '';
if (ct.includes('application/json')) { data = await resp.json(); } else { data = { error: await resp.text() }; }
if (resp.ok && data && !data.error) {
m.textContent = 'Удалено. Клиенты: ' + (data.clients_removed || 0);
setTimeout(() => location.reload(), 800);
} else {
m.textContent = (data && data.error) ? data.error : ('Ошибка удаления (' + resp.status + ')');
}
} catch (e) {
m.textContent = e.message || 'Ошибка связи';
}
btn.disabled = false;
});
});
});
function toggleTrafficInput() {
const select = document.getElementById('trafficSelect');
const input = document.getElementById('trafficMegabytes');
@@ -304,12 +498,11 @@ document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('createClientForm');
const clientNameInput = document.getElementById('clientName');
// Auto-replace spaces with underscores
if (clientNameInput) {
clientNameInput.addEventListener('input', function(e) {
// Replace only spaces with underscore, allow all other characters including Cyrillic
const clientLoginInput = document.getElementById('clientLogin');
if (clientLoginInput) {
clientLoginInput.addEventListener('input', function(e) {
let value = e.target.value;
let sanitized = value.replace(/ /g, '_');
let sanitized = value.replace(/\s+/g, '_');
if (value !== sanitized) {
e.target.value = sanitized;
}
+222 -2
View File
@@ -42,21 +42,54 @@
class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-language mr-2"></i>{{ t('settings.translations') }}
</a>
<a href="/tools/qr-decode"
class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-qrcode mr-2"></i>QR Декодер
</a>
{% if user.role == 'admin' %}
<a href="#" onclick="showTab('users'); return false;" id="tab-users"
class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-users mr-2"></i>{{ t('settings.users') }}
</a>
<a href="/settings/ldap"
<a href="#" onclick="showTab('ldap'); return false;" id="tab-ldap"
class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-network-wired mr-2"></i>LDAP
</a>
<a href="#" onclick="showTab('protocols'); return false;" id="tab-protocols"
class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-cubes mr-2"></i>{{ t('settings.protocol_management') }}
</a>
<a href="/admin/logs"
class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-file-alt mr-2"></i>{{ 'Логи' | trans }}
</a>
{% endif %}
</nav>
</div>
<!-- Profile Tab -->
<div id="content-profile" class="tab-content">
<div class="bg-white shadow rounded-lg mb-6">
<div class="px-6 py-5 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">
<i class="fas fa-user mr-2 text-purple-600"></i>Профиль
</h2>
</div>
<div class="px-6 py-5">
<form method="POST" action="/settings/profile">
<div class="space-y-4 max-w-md">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Отображаемое имя</label>
<input type="text" name="display_name" value="{{ user.display_name|default(user.name) }}"
class="block w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500">
</div>
<button type="submit" class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700">
<i class="fas fa-save mr-2"></i>{{ t('form.save') }}
</button>
</div>
</form>
</div>
</div>
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">
@@ -278,6 +311,11 @@
<i class="fas fa-trash"></i> {{ t('clients.delete') }}
</button>
</form>
{% else %}
<!-- For the current user show a Change Password action in Actions column -->
<a href="#" id="self-change-password" onclick="showTab('profile'); return false;" class="text-purple-600 hover:text-purple-900">
<i class="fas fa-key mr-1"></i> {{ t('settings.change_password') }}
</a>
{% endif %}
</td>
</tr>
@@ -286,6 +324,163 @@
</table>
</div>
</div>
<!-- LDAP Tab -->
<div id="content-ldap" class="tab-content hidden">
<div class="bg-white rounded-lg shadow-md p-6 mb-6 max-w-5xl">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900">{{ t('ldap.settings') }}</h2>
<button id="testConnection" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
{{ t('ldap.test_connection') }}
</button>
</div>
<form id="ldapForm" method="POST" action="/settings/ldap/save">
<div class="mb-6 p-4 bg-gray-50 rounded-lg">
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="enabled" value="1"
{% if config.enabled %}checked{% endif %}
class="w-5 h-5 text-blue-600 rounded focus:ring-2 focus:ring-blue-500">
<span class="ml-3 text-lg font-medium text-gray-900">{{ t('ldap.enable_ldap_auth') }}</span>
</label>
<p class="mt-2 ml-8 text-sm text-gray-600">{{ t('ldap.enable_description') }}</p>
</div>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.host') }} <span class="text-red-500">*</span>
</label>
<input type="text" name="host" value="{{ config.host }}" required
placeholder="ldap.example.com"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.port') }}
</label>
<input type="number" name="port" value="{{ config.port }}"
placeholder="389"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div>
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="use_tls" value="1"
{% if config.use_tls %}checked{% endif %}
class="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">{{ t('ldap.use_tls') }} (LDAPS)</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.base_dn') }} <span class="text-red-500">*</span>
</label>
<input type="text" name="base_dn" value="{{ config.base_dn }}" required
placeholder="dc=example,dc=com"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">{{ t('ldap.base_dn_description') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.bind_dn') }} <span class="text-red-500">*</span>
</label>
<input type="text" name="bind_dn" value="{{ config.bind_dn }}" required
placeholder="cn=admin,dc=example,dc=com"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">{{ t('ldap.bind_dn_description') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.bind_password') }} <span class="text-red-500">*</span>
</label>
<input type="password" name="bind_password" value="{{ config.bind_password }}" required
placeholder="••••••••"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.user_search_filter') }}
</label>
<input type="text" name="user_search_filter" value="{{ config.user_search_filter }}"
placeholder="(uid=%s)"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">{{ t('ldap.user_search_filter_description') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.group_search_filter') }}
</label>
<input type="text" name="group_search_filter" value="{{ config.group_search_filter }}"
placeholder="(memberUid=%s)"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ t('ldap.sync_interval') }}
</label>
<input type="number" name="sync_interval" value="{{ config.sync_interval }}"
placeholder="30"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<p class="mt-1 text-xs text-gray-500">{{ t('ldap.sync_interval_description') }}</p>
</div>
</div>
<div class="mt-6 flex gap-4">
<button type="submit" class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
{{ t('common.save') }}
</button>
<a href="/settings" class="px-6 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400">
{{ t('common.cancel') }}
</a>
</div>
</form>
</div>
<div class="bg-white rounded-lg shadow-md p-6 max-w-5xl">
<h3 class="text-xl font-bold text-gray-800 mb-4">{{ t('ldap.group_mappings') }}</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('ldap.group') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('ldap.role') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{{ t('ldap.description') }}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for mapping in mappings %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ mapping.ldap_group }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full
{% if mapping.role_name == 'admin' %}bg-red-100 text-red-800
{% elseif mapping.role_name == 'manager' %}bg-blue-100 text-blue-800
{% else %}bg-gray-100 text-gray-800{% endif %}">
{{ mapping.role_name }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500">{{ mapping.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Protocols Tab -->
<div id="content-protocols" class="tab-content hidden">
{% include 'settings/protocols_management.twig' %}
</div>
{% endif %}
</div>
@@ -341,8 +536,33 @@ function translateLanguage(lang) {
.catch(err => {
alert('{{ t('message.error') }}: ' + err.message);
button.disabled = false;
button.innerHTML = originalText;
button.innerHTML = originalText;
});
}
// LDAP test button inside settings page
document.addEventListener('click', function(e) {
if (e.target && e.target.id === 'testConnection') {
const btn = e.target;
btn.disabled = true;
btn.textContent = '{{ t('ldap.testing') }}...';
fetch('/settings/ldap/test', { method: 'POST' })
.then(r => r.json())
.then(result => {
if (result.success) {
alert('✓ ' + result.message);
} else {
alert('✗ ' + result.message);
}
})
.catch(err => {
alert('{{ t('ldap.connection_test_failed') }}: ' + err.message);
})
.finally(() => {
btn.disabled = false;
btn.textContent = '{{ t('ldap.test_connection') }}';
});
}
});
</script>
{% endblock %}
+343
View File
@@ -0,0 +1,343 @@
{% extends "layout.twig" %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">{{ 'Логи приложения' | trans }}</h1>
<p class="mt-2 text-sm text-gray-600">{{ 'Просмотр, поиск и управление файлами логов' | trans }}</p>
</div>
{% if log_files | length > 0 %}
<div class="flex space-x-2">
<button class="px-4 py-2 bg-yellow-500 text-white rounded-md hover:bg-yellow-600" id="btnClearAll" title="{{ 'Удалить все логи' | trans }}">
<i class="fas fa-trash mr-2"></i>{{ 'Очистить все' | trans }}
</button>
<a href="/admin/logs/download?file={{ selected_file }}"
class="px-4 py-2 rounded-md border {{ selected_file ? 'bg-white text-gray-700 hover:bg-gray-50' : 'bg-gray-100 text-gray-400 cursor-not-allowed' }}">
<i class="fas fa-download mr-2"></i>{{ 'Скачать' | trans }}
</a>
</div>
{% endif %}
</div>
{% if user and user.role == 'admin' %}
<div class="mb-6 border-b border-gray-200">
<nav class="-mb-px flex space-x-8">
<a href="/settings" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-user mr-2"></i>{{ t('settings.profile') }}
</a>
<a href="/settings" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-key mr-2"></i>{{ t('settings.api_keys') }}
</a>
<a href="/settings" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-language mr-2"></i>{{ t('settings.translations') }}
</a>
<a href="/settings" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-users mr-2"></i>{{ t('settings.users') }}
</a>
<a href="/settings/ldap" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-network-wired mr-2"></i>LDAP
</a>
<a href="/settings/protocols" class="tab-link border-transparent text-gray-500 hover:text-gray-700 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-cubes mr-2"></i>Protocols
</a>
<a href="/admin/logs" class="tab-link border-purple-500 text-purple-600 py-4 px-1 border-b-2 font-medium text-sm">
<i class="fas fa-file-alt mr-2"></i>{{ 'Логи' | trans }}
</a>
</nav>
</div>
{% endif %}
<div class="grid grid-cols-12 gap-6">
<!-- Sidebar with file list -->
<div class="col-span-12 md:col-span-3">
<div class="bg-white shadow rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">{{ 'Файлы логов' | trans }}</h2>
</div>
<div class="max-h-[600px] overflow-y-auto">
{% if log_files | length > 0 %}
{% for file in log_files %}
<a href="/admin/logs?file={{ file.path }}" class="block px-6 py-4 border-b border-gray-100 hover:bg-gray-50 {{ selected_file == file.path ? 'bg-purple-50' : '' }}">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="text-sm font-medium" style="word-break: break-all;">{{ file.name }}</div>
<div class="text-xs text-gray-500">{{ file.size_formatted }}</div>
</div>
<button type="button" class="text-red-600 hover:text-red-800 text-sm delete-log" data-file="{{ file.path }}" title="{{ 'Удалить' | trans }}">
<i class="fas fa-trash-alt"></i>
</button>
</div>
<div class="text-xs text-gray-500 mt-2">{{ file.modified_formatted }}</div>
</a>
{% endfor %}
{% else %}
<div class="px-6 py-10 text-center text-gray-500">
{{ 'Логи не найдены' | trans }}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Main content area -->
<div class="col-span-12 md:col-span-9">
{% if selected_file %}
<!-- File info -->
<div class="bg-white shadow rounded-lg mb-4">
<div class="px-6 py-4 border-b border-gray-200">
<div class="md:flex md:items-center md:justify-between">
<div class="mb-2 md:mb-0">
<h3 class="text-sm text-gray-700">{{ 'Файл:' | trans }} <code class="text-gray-900">{{ selected_file }}</code></h3>
</div>
<div class="text-xs text-gray-500">
<strong>{{ 'Размер:' | trans }}</strong> {{ file_size | default(0) | bytes_format }}
<span class="mx-2">•</span>
<strong>{{ 'Строк:' | trans }}</strong> {{ line_count | number_format(0, '.', ' ') }}
</div>
</div>
</div>
</div>
<!-- Search form -->
<div class="bg-white shadow rounded-lg mb-4">
<div class="px-6 py-4">
<form id="searchForm" class="md:flex md:items-center md:space-x-3">
<input type="hidden" name="file" value="{{ selected_file }}">
<div class="flex-1 mb-3 md:mb-0">
<input type="text" id="searchQuery" name="query" required
placeholder="{{ 'Поиск в логе...' | trans }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-purple-500 focus:border-purple-500">
</div>
<label class="inline-flex items-center mb-3 md:mb-0">
<input type="checkbox" id="caseSensitive" name="case_sensitive" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500">
<span class="ml-2 text-sm text-gray-600">{{ 'Учитывать регистр' | trans }}</span>
</label>
<button type="submit" class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700">
<i class="fas fa-search mr-2"></i>{{ 'Найти' | trans }}
</button>
<button type="button" class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200" id="statsBtn">
<i class="fas fa-chart-bar mr-2"></i>{{ 'Статистика' | trans }}
</button>
</form>
</div>
</div>
<!-- Search results -->
<div id="searchResults" class="hidden bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded mb-4">
<strong>{{ 'Результаты поиска:' | trans }}</strong>
<div id="resultsContent" class="mt-2 text-sm"></div>
</div>
<!-- Statistics -->
<div id="statsPanel" class="hidden bg-white shadow rounded-lg mb-4">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-sm font-medium text-gray-900">{{ 'Статистика логов' | trans }}</h3>
</div>
<div class="px-6 py-4">
<div class="grid grid-cols-4 gap-4">
<div class="text-center p-3 border-r">
<div class="text-2xl text-purple-600" id="totalLines">0</div>
<div class="text-xs text-gray-500">{{ 'Всего строк' | trans }}</div>
</div>
<div class="text-center p-3 border-r">
<div class="text-2xl text-red-600" id="errorCount">0</div>
<div class="text-xs text-gray-500">{{ 'Ошибок' | trans }}</div>
</div>
<div class="text-center p-3 border-r">
<div class="text-2xl text-yellow-600" id="warningCount">0</div>
<div class="text-xs text-gray-500">{{ 'Предупреждений' | trans }}</div>
</div>
<div class="text-center p-3">
<div class="text-2xl text-green-600" id="successCount">0</div>
<div class="text-xs text-gray-500">{{ 'Успехов' | trans }}</div>
</div>
</div>
<div class="text-xs text-gray-500 mt-2" id="lastModified"></div>
</div>
</div>
<!-- Log content -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-3 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900">{{ 'Содержание логов' | trans }}</h3>
<button type="button" class="px-2 py-1 text-sm border rounded hover:bg-gray-50" id="toggleLineNumbers">
<i class="fas fa-list-ol"></i>
</button>
</div>
<div class="px-6 py-4">
<pre class="mb-0 max-h-[600px] overflow-auto"><code id="logContent" class="show-line-numbers">{{ log_content }}</code></pre>
</div>
</div>
{% else %}
<div class="bg-blue-50 border border-blue-200 text-blue-800 px-6 py-10 rounded text-center">
<i class="fas fa-info-circle text-2xl mb-3"></i>
<div class="text-lg">{{ 'Выберите файл логов для просмотра' | trans }}</div>
</div>
{% endif %}
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const selectedFile = '{{ selected_file }}';
// Delete log file
document.querySelectorAll('.delete-log').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const file = this.dataset.file;
if (confirm('{{ "Удалить этот файл логов?" | trans }}')) {
fetch('/admin/logs/delete', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'file=' + encodeURIComponent(file)
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = data.redirect;
} else {
alert('{{ "Ошибка:" | trans }} ' + data.message);
}
});
}
});
});
// Clear all logs
const clearAllBtn = document.getElementById('btnClearAll');
if (clearAllBtn) {
clearAllBtn.addEventListener('click', function() {
if (confirm('{{ "Удалить ВСЕ файлы логов? Это действие необратимо." | trans }}')) {
fetch('/admin/logs/clear-all', {method: 'POST'})
.then(r => r.json())
.then(data => {
alert(data.message);
window.location.href = data.redirect;
});
}
});
}
// Search logs
document.getElementById('searchForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch('/admin/logs/search', {
method: 'POST',
body: new URLSearchParams(formData)
})
.then(r => r.json())
.then(data => {
if (data.success) {
showSearchResults(data);
} else {
alert('{{ "Ошибка:" | trans }} ' + data.message);
}
});
});
// Statistics
document.getElementById('statsBtn').addEventListener('click', function() {
if (!selectedFile) return;
fetch('/admin/logs/stats', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'file=' + encodeURIComponent(selectedFile)
})
.then(r => r.json())
.then(data => {
if (data.success) {
showStatistics(data);
}
});
});
// Toggle line numbers
document.getElementById('toggleLineNumbers').addEventListener('click', function() {
const content = document.getElementById('logContent');
content.classList.toggle('show-line-numbers');
});
function showSearchResults(data) {
const resultsDiv = document.getElementById('searchResults');
const resultsContent = document.getElementById('resultsContent');
if (data.results_count === 0) {
resultsContent.innerHTML = '<p class="mb-0">{{ "Результатов не найдено" | trans }}</p>';
} else {
let html = '<p class="mb-2">{{ "Найдено совпадений:" | trans }} <strong>' + data.results_count + '</strong></p>';
html += '<div class="results-list" style="max-height: 300px; overflow-y: auto;">';
data.results.forEach((result, idx) => {
html += '<div class="border-bottom pb-2 mb-2">';
html += '<small class="text-muted">{{ "Строка" | trans }} ' + result.line + ':</small><br>';
html += '<code>' + escapeHtml(result.content.substring(0, 200)) + (result.content.length > 200 ? '...' : '') + '</code>';
html += '</div>';
});
html += '</div>';
resultsContent.innerHTML = html;
}
resultsDiv.style.display = 'block';
}
function showStatistics(data) {
document.getElementById('totalLines').textContent = data.total_lines;
document.getElementById('errorCount').textContent = data.errors;
document.getElementById('warningCount').textContent = data.warnings;
document.getElementById('successCount').textContent = data.success;
document.getElementById('lastModified').textContent = '{{ "Последнее обновление:" | trans }} ' + data.last_modified;
document.getElementById('statsPanel').style.display = 'block';
}
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
});
</script>
<style>
#logContent {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
line-height: 1.4;
}
#logContent.show-line-numbers {
counter-reset: line;
}
#logContent.show-line-numbers::before {
content: '';
}
.results-list {
border: 1px solid #ddd;
border-radius: 3px;
padding: 10px;
background: #fafafa;
}
.list-group-item.active {
background-color: #007bff;
border-color: #007bff;
}
</style>
{% endblock %}
+707
View File
@@ -0,0 +1,707 @@
{% extends "layout.twig" %}
{% block title %}{{ editing ? t('protocols.edit_protocol') : t('protocols.create_protocol') }} - {{ parent() }}{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900">{{ editing ? t('protocols.edit_protocol') : t('protocols.create_protocol') }}</h1>
<p class="mt-2 text-gray-600">{{ editing ? t('protocols.edit_protocol_description') : t('protocols.create_protocol_description') }}</p>
</div>
<a href="/settings/protocols" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
{{ t('protocols.back_to_protocols') }}
</a>
</div>
</div>
<!-- Success/Error Messages -->
{% if success %}
<div class="mb-4 bg-green-50 border border-green-200 rounded-md p-4">
<div class="flex">
<svg class="w-5 h-5 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<p class="text-green-800">{{ success }}</p>
</div>
</div>
{% endif %}
{% if error %}
<div class="mb-4 bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex">
<svg class="w-5 h-5 text-red-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<p class="text-red-800">{{ error }}</p>
</div>
</div>
{% endif %}
<!-- Protocol Form -->
<form id="protocol-form" method="POST" action="/settings/protocols/save" class="space-y-6">
{% if editing %}
<input type="hidden" name="id" value="{{ editing.id }}">
{% endif %}
<!-- Basic Information -->
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">{{ t('protocols.basic_information') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">{{ t('protocols.name_label') }} *</label>
<input type="text" id="name" name="name" value="{{ editing.name ?? '' }}" required class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="mt-1 text-sm text-gray-500">{{ t('protocols.name_help') }}</p>
</div>
<div>
<label for="slug" class="block text-sm font-medium text-gray-700 mb-2">{{ t('protocols.slug_label') }} *</label>
<input type="text" id="slug" name="slug" value="{{ editing.slug ?? '' }}" required pattern="[a-z0-9_-]+" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="mt-1 text-sm text-gray-500">{{ t('protocols.slug_help') }}</p>
</div>
</div>
<div class="mt-6">
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.description') }}</label>
<textarea id="description" name="description" rows="3" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">{{ editing.description ?? '' }}</textarea>
<p class="mt-1 text-sm text-gray-500">{{ t('protocols.description_help') }}</p>
</div>
</div>
<!-- Installation Script -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-medium text-gray-900">{{ t('protocols.installation_script') }}</h2>
<button type="button" id="ai-help-btn" class="inline-flex items-center px-3 py-1 border border-purple-300 rounded-md text-sm font-medium text-purple-700 bg-white hover:bg-purple-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
{{ t('ai.get_ai_help') }}
</button>
</div>
<div>
<textarea id="install_script" name="install_script" rows="15" class="w-full px-3 py-2 border border-gray-300 rounded-md font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="#!/bin/bash&#10;# Installation script here">{{ editing.install_script ?? '' }}</textarea>
<p class="mt-1 text-sm text-gray-500">{{ t('protocols.install_script_help') }}</p>
<div class="mt-3 flex items-center space-x-2">
<button id="test-install-btn" type="button" class="inline-flex items-center px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4"/></svg>
{{ t('protocols.test_install') }}
</button>
<span class="text-xs text-gray-500">{{ t('protocols.testing_on_ubuntu22') }}</span>
</div>
<div id="test-install-result" class="mt-3 hidden">
<h3 class="text-sm font-medium text-gray-900">{{ t('protocols.test_result') }}</h3>
<pre id="test-install-output" class="mt-2 p-3 bg-gray-50 border border-gray-200 rounded text-xs whitespace-pre-wrap"></pre>
<h3 class="mt-3 text-sm font-medium text-gray-900">{{ t('protocols.client_output_preview') }}</h3>
<pre id="test-client-preview" class="mt-2 p-3 bg-gray-50 border border-gray-200 rounded text-xs whitespace-pre-wrap"></pre>
</div>
</div>
</div>
<!-- Uninstallation Script -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-medium text-gray-900">{{ t('protocols.uninstallation_script') }}</h2>
<button type="button" class="ai-help-btn inline-flex items-center px-3 py-1 border border-purple-300 rounded-md text-sm font-medium text-purple-700 bg-white hover:bg-purple-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" data-target="uninstall">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
{{ t('ai.get_ai_help') }}
</button>
</div>
<div>
<textarea id="uninstall_script" name="uninstall_script" rows="12" class="w-full px-3 py-2 border border-gray-300 rounded-md font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="#!/bin/bash&#10;# Uninstallation script here">{{ editing.uninstall_script ?? '' }}</textarea>
<p class="mt-1 text-sm text-gray-500">{{ t('protocols.uninstall_script_help') }}</p>
<div class="mt-3 flex items-center space-x-2">
<button id="test-uninstall-btn" type="button" class="inline-flex items-center px-3 py-2 bg-red-600 text-white rounded hover:bg-red-700">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
{{ t('protocols.test_uninstall') }}
</button>
<span class="text-xs text-gray-500">{{ t('protocols.testing_on_ubuntu22') }}</span>
</div>
<div id="test-uninstall-result" class="mt-3 hidden">
<h3 class="text-sm font-medium text-gray-900">{{ t('protocols.test_result') }}</h3>
<pre id="test-uninstall-output" class="mt-2 p-3 bg-gray-50 border border-gray-200 rounded text-xs whitespace-pre-wrap"></pre>
</div>
</div>
</div>
<!-- Output Template -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-medium text-gray-900">{{ t('protocols.output_template') }}</h2>
<button type="button" class="ai-help-btn inline-flex items-center px-3 py-1 border border-purple-300 rounded-md text-sm font-medium text-purple-700 bg-white hover:bg-purple-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" data-target="template">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
{{ t('ai.get_ai_help') }}
</button>
</div>
<div>
<textarea id="output_template" name="output_template" rows="10" class="w-full px-3 py-2 border border-gray-300 rounded-md font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="[Interface]&#10;PrivateKey = {{private_key}}&#10;Address = {{client_ip}}/32">{{ editing.output_template ?? '' }}</textarea>
<p class="mt-1 text-sm text-gray-500">{{ t('protocols.output_template_help') }}</p>
</div>
<div class="mt-4 p-4 bg-blue-50 rounded-md">
<h3 class="text-sm font-medium text-blue-900 mb-2">{{ t('protocols.available_variables') }}</h3>
<div class="text-sm text-blue-800 space-y-1">
<p><code>{{private_key}}</code> - {{ t('protocols.variable_private_key_help') }}</p>
<p><code>{{public_key}}</code> - {{ t('protocols.variable_public_key_help') }}</p>
<p><code>{{client_ip}}</code> - {{ t('protocols.variable_client_ip_help') }}</p>
<p><code>{{server_host}}</code> - {{ t('protocols.variable_server_host_help') }}</p>
<p><code>{{server_port}}</code> - {{ t('protocols.variable_server_port_help') }}</p>
<p><code>{{preshared_key}}</code> - {{ t('protocols.variable_preshared_key_help') }}</p>
</div>
</div>
</div>
<!-- QR Code Template -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center">
<input type="checkbox" id="qr_section_toggle" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-3" checked>
<h2 class="text-lg font-medium text-gray-900">{{ t('protocols.qr_code_template') }}</h2>
</div>
<button type="button" class="ai-help-btn inline-flex items-center px-3 py-1 border border-purple-300 rounded-md text-sm font-medium text-purple-700 bg-white hover:bg-purple-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" data-target="qr_template">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
{{ t('ai.get_ai_help') }}
</button>
</div>
<div id="qr_section_content">
<div class="mb-4">
<label for="qr_code_format" class="block text-sm font-medium text-gray-700 mb-2">{{ t('protocols.qr_code_format') }}</label>
<select id="qr_code_format" name="qr_code_format" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="amnezia_compressed" {% if editing.qr_code_format == 'amnezia_compressed' %}selected{% endif %}>Amnezia Compressed (Default)</option>
<option value="raw" {% if editing.qr_code_format == 'raw' %}selected{% endif %}>Raw Content</option>
<option value="text" {% if editing.qr_code_format == 'text' %}selected{% endif %}>{{ t('protocols.qr_code_format_text') }}</option>
</select>
<p class="mt-1 text-sm text-gray-500">{{ t('protocols.qr_code_format_help') }}</p>
</div>
<div>
<textarea id="qr_code_template" name="qr_code_template" rows="10" class="w-full px-3 py-2 border border-gray-300 rounded-md font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="{&quot;last_config&quot;:{{last_config_json}}}">{{ editing.qr_code_template ?? '' }}</textarea>
<p class="mt-1 text-sm text-gray-500">{{ t('protocols.qr_code_template_help') }}</p>
</div>
<div class="mt-4 p-4 bg-blue-50 rounded-md">
<h3 class="text-sm font-medium text-blue-900 mb-2">{{ t('protocols.available_variables') }}</h3>
<div class="text-sm text-blue-800 space-y-1">
<p><code>{{last_config_json}}</code> - {{ t('protocols.variable_last_config_json_help') }}</p>
<p>{{ t('protocols.plus_all_output_variables') }}</p>
</div>
</div>
</div>
</div>
<!-- Password Generation -->
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">{{ t('protocols.password_generation') }}</h2>
<div>
<textarea id="password_command" name="password_command" rows="6" class="w-full px-3 py-2 border rounded-md font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="echo \$(openssl rand -base64 12)">{{ editing.password_command ?? '' }}</textarea>
<p class="mt-1 text-sm text-gray-500">{{ t('protocols.password_command_help') }}</p>
</div>
</div>
<!-- Settings -->
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">{{ t('common.settings') }}</h2>
<div class="space-y-4">
<div class="flex items-center">
<input type="checkbox" id="ubuntu_compatible" name="ubuntu_compatible" value="1" {% if editing.ubuntu_compatible %}checked{% endif %} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ubuntu_compatible" class="ml-2 block text-sm text-gray-900">{{ t('protocols.ubuntu_compatible') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" id="show_text_content" name="show_text_content" value="1" {% if editing.show_text_content %}checked{% endif %} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="show_text_content" class="ml-2 block text-sm text-gray-900">{{ t('protocols.show_text_content') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" id="is_active" name="is_active" value="1" {% if editing.is_active is not defined or editing.is_active %}checked{% endif %} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="is_active" class="ml-2 block text-sm text-gray-900">{{ t('protocols.active_label') }}</label>
</div>
</div>
</div>
<!-- Form Actions -->
<div class="flex justify-end space-x-3">
<a href="/settings/protocols" class="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
{{ t('common.cancel') }}
</a>
<button type="submit" class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
{{ editing ? t('protocols.update_protocol') : t('protocols.create_protocol') }}
</button>
</div>
</form>
</div>
</div>
<!-- AI Assistant Modal -->
<div id="ai-assistant-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-4xl shadow-lg rounded-md bg-white">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900">{{ t('ai.assistant') }}</h3>
<button id="close-ai-modal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('ai.select_model') }}</label>
<select id="ai-model-select" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="openai/gpt-3.5-turbo">{{ t('ai.model_gpt35_turbo') }}</option>
<option value="openai/gpt-4">{{ t('ai.model_gpt4') }}</option>
<option value="anthropic/claude-3-haiku">{{ t('ai.model_claude3_haiku') }}</option>
<option value="anthropic/claude-3-sonnet">{{ t('ai.model_claude3_sonnet') }}</option>
</select>
<div class="mt-2 flex items-center space-x-2">
<input id="ai-model-custom" type="text" placeholder="{{ t('ai.custom_model_placeholder') }}" class="flex-1 px-3 py-2 border border-gray-300 rounded-md">
<button id="ai-model-test-btn" type="button" class="px-3 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50">{{ t('ai.check_availability') }}</button>
</div>
<p class="mt-2 text-xs text-gray-500">
<i class="fas fa-external-link-alt"></i>
<a href="https://openrouter.ai/models" target="_blank" class="text-purple-600">openrouter.ai/models</a>
</p>
{% if not openrouter_key %}
<div class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded">
<div class="flex items-center justify-between">
<span class="text-xs text-yellow-800">{{ t('settings.no_api_key') }}</span>
<a href="/settings#api" class="px-2 py-1 text-xs bg-purple-600 text-white rounded">{{ t('settings.enter_api_key') }}</a>
</div>
</div>
{% endif %}
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('ai.protocol_type') }}</label>
<select id="ai-protocol-type" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">{{ t('ai.general_vpn') }}</option>
<option value="wireguard">WireGuard</option>
<option value="openvpn">OpenVPN</option>
<option value="shadowsocks">Shadowsocks</option>
<option value="cloak">Cloak</option>
<option value="ikev2">IKEv2</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('ai.describe_requirements') }}</label>
<textarea id="ai-prompt" rows="4" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="{{ t('ai.prompt_placeholder') }}"></textarea>
</div>
<div class="mb-4">
<button id="generate-script-btn" class="w-full inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
{{ t('ai.generate_script') }}
</button>
</div>
<div id="ai-loading" class="hidden text-center py-4">
<div class="inline-flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-purple-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ t('ai.generating_script') }}</span>
</div>
</div>
<div id="ai-result" class="hidden">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('ai.generated_script') }}</label>
<div class="bg-gray-900 text-green-400 p-4 rounded-md overflow-x-auto">
<pre id="generated-script" class="text-sm whitespace-pre-wrap"></pre>
</div>
</div>
<div id="ai-suggestions" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('ai.suggestions') }}</label>
<ul id="suggestions-list" class="list-disc list-inside space-y-1 text-sm text-gray-600"></ul>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.compatibility') }}</label>
<div id="ubuntu-compatibility" class="flex items-center"></div>
</div>
<div class="flex space-x-3">
<button id="apply-to-current-btn" class="flex-1 inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
{{ t('ai.apply_to_current_protocol') }}
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// AI Assistant Modal
const aiModal = document.getElementById('ai-assistant-modal');
const aiHelpBtns = document.querySelectorAll('.ai-help-btn');
const closeAIModal = document.getElementById('close-ai-modal');
const generateScriptBtn = document.getElementById('generate-script-btn');
const aiLoading = document.getElementById('ai-loading');
const aiResult = document.getElementById('ai-result');
const applyToCurrentBtn = document.getElementById('apply-to-current-btn');
const installScriptTextarea = document.getElementById('install_script');
const uninstallScriptTextarea = document.getElementById('uninstall_script');
const outputTemplateTextarea = document.getElementById('output_template');
// QR Template Section Toggle
const qrSectionToggle = document.getElementById('qr_section_toggle');
const qrSectionContent = document.getElementById('qr_section_content');
if (qrSectionToggle && qrSectionContent) {
qrSectionToggle.addEventListener('change', function() {
qrSectionContent.style.display = this.checked ? 'block' : 'none';
});
}
let currentAiTarget = 'install'; // install, uninstall, template
function showAIModal(target) {
currentAiTarget = target || 'install';
aiModal.classList.remove('hidden');
aiResult.classList.add('hidden');
aiLoading.classList.add('hidden');
// Update modal title or prompt placeholder based on target if needed
const promptArea = document.getElementById('ai-prompt');
if (currentAiTarget === 'template') {
promptArea.placeholder = "{{ t('ai.prompt_placeholder_template') }}";
} else if (currentAiTarget === 'qr_template') {
promptArea.placeholder = "{{ t('ai.prompt_placeholder_qr_template') }}";
} else if (currentAiTarget === 'uninstall') {
promptArea.placeholder = "{{ t('ai.prompt_placeholder_uninstall') }}";
} else {
promptArea.placeholder = "{{ t('ai.prompt_placeholder') }}";
}
}
function hideAIModal() {
aiModal.classList.add('hidden');
}
// Attach event listeners to all AI help buttons
aiHelpBtns.forEach(btn => {
btn.addEventListener('click', function() {
const target = this.getAttribute('data-target') || 'install';
showAIModal(target);
});
});
// Also attach to the original ID if it exists (for backward compatibility or if I missed updating one)
const originalAiBtn = document.getElementById('ai-help-btn');
if (originalAiBtn) {
originalAiBtn.addEventListener('click', function() {
showAIModal('install');
});
}
closeAIModal.addEventListener('click', hideAIModal);
// Close modal when clicking outside
aiModal.addEventListener('click', function(e) {
if (e.target === aiModal) {
hideAIModal();
}
});
// Generate script with AI
generateScriptBtn.addEventListener('click', async function() {
const model = document.getElementById('ai-model-select').value;
const customModel = document.getElementById('ai-model-custom').value.trim();
const effectiveModel = customModel !== '' ? customModel : model;
const protocolType = document.getElementById('ai-protocol-type').value;
const prompt = document.getElementById('ai-prompt').value;
if (!prompt.trim()) {
alert('{{ t('ai.please_enter_requirements') }}');
return;
}
aiLoading.classList.remove('hidden');
generateScriptBtn.disabled = true;
try {
const response = await fetch('/api/ai/assist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: prompt,
model: effectiveModel,
protocol_type: protocolType,
target: currentAiTarget
})
});
const result = await response.json();
if (result.success) {
displayAIResult(result.data);
} else {
alert('{{ t('ai.error_generating_script') }}: ' + result.error);
}
} catch (error) {
alert('{{ t('ai.error_generating_script') }}: ' + error.message);
} finally {
aiLoading.classList.add('hidden');
generateScriptBtn.disabled = false;
}
});
function displayAIResult(data) {
document.getElementById('generated-script').textContent = data.script;
const suggestionsList = document.getElementById('suggestions-list');
suggestionsList.innerHTML = '';
data.suggestions.forEach(suggestion => {
const li = document.createElement('li');
li.textContent = suggestion;
suggestionsList.appendChild(li);
});
const compatibilityDiv = document.getElementById('ubuntu-compatibility');
if (data.ubuntu_compatible) {
compatibilityDiv.innerHTML = '<svg class="w-5 h-5 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg><span class="text-green-700">Compatible with Ubuntu 22.04-24.04</span>';
} else {
compatibilityDiv.innerHTML = '<svg class="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg><span class="text-red-700">May not be compatible with Ubuntu 22.04-24.04</span>';
}
aiResult.classList.remove('hidden');
}
// Apply to current protocol
applyToCurrentBtn.addEventListener('click', function() {
const generatedScript = document.getElementById('generated-script').textContent;
if (generatedScript && confirm('{{ t('ai.confirm_apply_script') }}')) {
if (currentAiTarget === 'uninstall') {
uninstallScriptTextarea.value = generatedScript;
} else if (currentAiTarget === 'template') {
outputTemplateTextarea.value = generatedScript;
} else if (currentAiTarget === 'qr_template') {
document.getElementById('qr_code_template').value = generatedScript;
} else {
installScriptTextarea.value = generatedScript;
}
hideAIModal();
}
});
// Form validation
document.getElementById('protocol-form').addEventListener('submit', function(e) {
const name = document.getElementById('name').value.trim();
const slug = document.getElementById('slug').value.trim();
if (!name || !slug) {
e.preventDefault();
alert('{{ t('protocols.please_fill_required_fields') }}');
return;
}
if (!/^[a-z0-9_-]+$/i.test(slug)) {
e.preventDefault();
alert('{{ t('protocols.invalid_slug_format') }}');
return;
}
});
// Auto-generate slug from name
document.getElementById('name').addEventListener('blur', function() {
const name = this.value.trim();
const slugField = document.getElementById('slug');
if (name && !slugField.value.trim()) {
slugField.value = name.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
});
// Test Install Script
const testBtn = document.getElementById('test-install-btn');
const testBox = document.getElementById('test-install-result');
const testOut = document.getElementById('test-install-output');
const clientPrev = document.getElementById('test-client-preview');
if (testBtn) {
testBtn.addEventListener('click', function() {
const protocolId = {{ editing ? editing.id : 'null' }};
if (!protocolId) {
return;
}
testBtn.disabled = true;
testBtn.classList.add('opacity-50');
testOut.textContent = '';
clientPrev.textContent = '';
testBox.classList.remove('hidden');
const appendCmd = (cmd) => {
const line = document.createElement('div');
line.className = 'text-xs text-gray-800';
line.innerHTML = `<span class="text-blue-600">$</span> <code>${cmd}</code>`;
testOut.appendChild(line);
};
const appendOut = (text) => {
const pre = document.createElement('pre');
pre.className = 'mt-1 p-2 bg-gray-100 border border-gray-200 rounded text-xs whitespace-pre-wrap';
pre.textContent = text;
testOut.appendChild(pre);
};
const setError = (msg) => {
const err = document.createElement('div');
err.className = 'mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-700';
err.textContent = msg;
testOut.appendChild(err);
};
let es;
try {
es = new EventSource(`/api/protocols/${protocolId}/test-install/stream`);
} catch (e) {
es = null;
}
if (es) {
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'start') {
appendOut('{{ t('protocols.testing_on_ubuntu22') }}');
} else if (data.type === 'cmd') {
appendCmd(data.cmd);
} else if (data.type === 'out') {
appendOut(data.line);
} else if (data.type === 'cmd_done') {
if (data.rc !== 0) {
setError('Command failed');
}
} else if (data.type === 'preview') {
clientPrev.textContent = data.preview || '';
} else if (data.type === 'done') {
es.close();
testBtn.disabled = false;
testBtn.classList.remove('opacity-50');
} else if (data.type === 'error') {
setError(data.error || 'Unknown error');
es.close();
testBtn.disabled = false;
testBtn.classList.remove('opacity-50');
}
} catch (_) {}
};
es.onerror = () => {
es.close();
testBtn.disabled = false;
testBtn.classList.remove('opacity-50');
setError('Connection failed');
};
} else {
// Fallback to non-stream if needed, but we implemented stream
testBtn.disabled = false;
testBtn.classList.remove('opacity-50');
}
});
}
// Test Uninstall Script
const testUninstallBtn = document.getElementById('test-uninstall-btn');
const testUninstallBox = document.getElementById('test-uninstall-result');
const testUninstallOut = document.getElementById('test-uninstall-output');
if (testUninstallBtn) {
testUninstallBtn.addEventListener('click', function() {
const protocolId = {{ editing ? editing.id : 'null' }};
if (!protocolId) {
return;
}
testUninstallBtn.disabled = true;
testUninstallBtn.classList.add('opacity-50');
testUninstallOut.textContent = '';
testUninstallBox.classList.remove('hidden');
const appendCmd = (cmd) => {
const line = document.createElement('div');
line.className = 'text-xs text-gray-800';
line.innerHTML = `<span class="text-blue-600">$</span> <code>${cmd}</code>`;
testUninstallOut.appendChild(line);
};
const appendOut = (text) => {
const pre = document.createElement('pre');
pre.className = 'mt-1 p-2 bg-gray-100 border border-gray-200 rounded text-xs whitespace-pre-wrap';
pre.textContent = text;
testUninstallOut.appendChild(pre);
};
const setError = (msg) => {
const err = document.createElement('div');
err.className = 'mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-700';
err.textContent = msg;
testUninstallOut.appendChild(err);
};
let es;
try {
es = new EventSource(`/api/protocols/${protocolId}/test-uninstall/stream`);
} catch (e) {
es = null;
}
if (es) {
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'start') {
appendOut('{{ t('protocols.testing_on_ubuntu22') }}');
} else if (data.type === 'cmd') {
appendCmd(data.cmd);
} else if (data.type === 'out') {
appendOut(data.line);
} else if (data.type === 'cmd_done') {
if (data.rc !== 0) {
setError('Command failed');
}
} else if (data.type === 'done') {
es.close();
testUninstallBtn.disabled = false;
testUninstallBtn.classList.remove('opacity-50');
} else if (data.type === 'error') {
setError(data.error || 'Unknown error');
es.close();
testUninstallBtn.disabled = false;
testUninstallBtn.classList.remove('opacity-50');
}
} catch (_) {}
};
es.onerror = () => {
es.close();
testUninstallBtn.disabled = false;
testUninstallBtn.classList.remove('opacity-50');
setError('Connection failed');
};
}
});
}
});
</script>
{% endblock %}
@@ -0,0 +1,272 @@
{% extends "layout.twig" %}
{% block title %}{{ t('protocols.template_editor') }} - {{ parent() }}{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900">{{ t('protocols.template_editor') }}</h1>
<p class="mt-2 text-gray-600">{{ t('protocols.template_editor_description') }}</p>
</div>
<a href="/settings/protocols" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
{{ t('protocols.back_to_protocols') }}
</a>
</div>
</div>
<!-- Template Editor -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">{{ protocol.name }} - {{ t('protocols.output_template') }}</h2>
<p class="mt-1 text-sm text-gray-600">{{ t('protocols.template_editor_help') }}</p>
</div>
<div class="p-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Template Editor -->
<div>
<div class="flex justify-between items-center mb-3">
<label class="block text-sm font-medium text-gray-700">{{ t('protocols.template_content') }}</label>
<div class="flex space-x-2">
<button id="format-template" class="px-3 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200">{{ t('common.format') }}</button>
<button id="clear-template" class="px-3 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200">{{ t('common.clear') }}</button>
</div>
</div>
<textarea id="template-editor" rows="20" class="w-full px-3 py-2 border border-gray-300 rounded-md font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">{{ protocol.output_template }}</textarea>
<div class="mt-3 flex space-x-2">
<button id="save-template" class="flex-1 inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"/>
</svg>
{{ t('protocols.save_template') }}
</button>
<button id="preview-template" class="flex-1 inline-flex justify-center items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ t('common.preview') }}
</button>
</div>
</div>
<!-- Preview Panel -->
<div>
<div class="flex justify-between items-center mb-3">
<label class="block text-sm font-medium text-gray-700">{{ t('common.preview') }}</label>
<button id="refresh-preview" class="px-3 py-1 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200">{{ t('common.refresh') }}</button>
</div>
<div class="bg-gray-900 text-green-400 p-4 rounded-md h-96 overflow-auto">
<pre id="template-preview" class="text-sm whitespace-pre-wrap">{{ t('protocols.click_preview_to_see_output') }}</pre>
</div>
<div class="mt-3">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('protocols.test_variables') }}</label>
<div class="space-y-2">
<input type="text" id="test-private-key" placeholder="{{ t('protocols.private_key') }}" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" value="test_private_key_example_1234567890abcdef">
<input type="text" id="test-client-ip" placeholder="{{ t('protocols.client_ip') }}" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" value="10.8.1.2">
<input type="text" id="test-server-host" placeholder="{{ t('protocols.server_host') }}" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" value="vpn.example.com">
<input type="text" id="test-server-port" placeholder="{{ t('protocols.server_port') }}" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" value="51820">
<input type="text" id="test-preshared-key" placeholder="{{ t('protocols.preshared_key') }}" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" value="test_preshared_key_example">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Template Variables -->
<div class="mt-6 bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">{{ t('protocols.template_variables') }}</h2>
<p class="mt-1 text-sm text-gray-600">{{ t('protocols.template_variables_help') }}</p>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="variable-card p-3 border border-gray-200 rounded-md">
<div class="flex justify-between items-center">
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{private_key}}</code>
<button class="copy-variable text-xs text-blue-600 hover:text-blue-800" data-variable="{{private_key}}">{{ t('common.copy') }}</button>
</div>
<p class="mt-2 text-xs text-gray-600">{{ t('protocols.variable_private_key_desc') }}</p>
</div>
<div class="variable-card p-3 border border-gray-200 rounded-md">
<div class="flex justify-between items-center">
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{public_key}}</code>
<button class="copy-variable text-xs text-blue-600 hover:text-blue-800" data-variable="{{public_key}}">{{ t('common.copy') }}</button>
</div>
<p class="mt-2 text-xs text-gray-600">{{ t('protocols.variable_public_key_desc') }}</p>
</div>
<div class="variable-card p-3 border border-gray-200 rounded-md">
<div class="flex justify-between items-center">
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{client_ip}}</code>
<button class="copy-variable text-xs text-blue-600 hover:text-blue-800" data-variable="{{client_ip}}">{{ t('common.copy') }}</button>
</div>
<p class="mt-2 text-xs text-gray-600">{{ t('protocols.variable_client_ip_desc') }}</p>
</div>
<div class="variable-card p-3 border border-gray-200 rounded-md">
<div class="flex justify-between items-center">
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{server_host}}</code>
<button class="copy-variable text-xs text-blue-600 hover:text-blue-800" data-variable="{{server_host}}">{{ t('common.copy') }}</button>
</div>
<p class="mt-2 text-xs text-gray-600">{{ t('protocols.variable_server_host_desc') }}</p>
</div>
<div class="variable-card p-3 border border-gray-200 rounded-md">
<div class="flex justify-between items-center">
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{server_port}}</code>
<button class="copy-variable text-xs text-blue-600 hover:text-blue-800" data-variable="{{server_port}}">{{ t('common.copy') }}</button>
</div>
<p class="mt-2 text-xs text-gray-600">{{ t('protocols.variable_server_port_desc') }}</p>
</div>
<div class="variable-card p-3 border border-gray-200 rounded-md">
<div class="flex justify-between items-center">
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{preshared_key}}</code>
<button class="copy-variable text-xs text-blue-600 hover:text-blue-800" data-variable="{{preshared_key}}">{{ t('common.copy') }}</button>
</div>
<p class="mt-2 text-xs text-gray-600">{{ t('protocols.variable_preshared_key_desc') }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const templateEditor = document.getElementById('template-editor');
const templatePreview = document.getElementById('template-preview');
const saveTemplateBtn = document.getElementById('save-template');
const previewBtn = document.getElementById('preview-template');
const refreshBtn = document.getElementById('refresh-preview');
const formatBtn = document.getElementById('format-template');
const clearBtn = document.getElementById('clear-template');
const testPrivateKey = document.getElementById('test-private-key');
const testClientIp = document.getElementById('test-client-ip');
const testServerHost = document.getElementById('test-server-host');
const testServerPort = document.getElementById('test-server-port');
const testPresharedKey = document.getElementById('test-preshared-key');
// Preview template
function previewTemplate() {
let template = templateEditor.value;
// Replace variables with test values
template = template.replace(/\{\{private_key\}\}/g, testPrivateKey.value);
template = template.replace(/\{\{public_key\}\}/g, 'test_public_key_example');
template = template.replace(/\{\{client_ip\}\}/g, testClientIp.value);
template = template.replace(/\{\{server_host\}\}/g, testServerHost.value);
template = template.replace(/\{\{server_port\}\}/g, testServerPort.value);
template = template.replace(/\{\{preshared_key\}\}/g, testPresharedKey.value);
templatePreview.textContent = template;
}
previewBtn.addEventListener('click', previewTemplate);
refreshBtn.addEventListener('click', previewTemplate);
// Auto-preview on input change
[testPrivateKey, testClientIp, testServerHost, testServerPort, testPresharedKey].forEach(input => {
input.addEventListener('input', function() {
if (templatePreview.textContent !== '{{ t('protocols.click_preview_to_see_output') }}') {
previewTemplate();
}
});
});
// Save template
saveTemplateBtn.addEventListener('click', function() {
const protocolId = {{ protocol.id }};
const template = templateEditor.value;
fetch(`/api/protocols/${protocolId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
output_template: template
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert('{{ t('protocols.template_saved_successfully') }}');
} else {
alert('{{ t('protocols.error_saving_template') }}: ' + result.error);
}
})
.catch(error => {
alert('{{ t('protocols.error_saving_template') }}: ' + error.message);
});
});
// Format template (basic formatting)
formatBtn.addEventListener('click', function() {
let template = templateEditor.value;
// Basic formatting for WireGuard configs
if (template.includes('[Interface]') || template.includes('[Peer]')) {
template = template.replace(/\n\s*/g, '\n');
template = template.replace(/\[/g, '\n[');
template = template.trim();
}
templateEditor.value = template;
alert('{{ t('protocols.template_formatted') }}');
});
// Clear template
clearBtn.addEventListener('click', function() {
if (confirm('{{ t('protocols.confirm_clear_template') }}')) {
templateEditor.value = '';
templatePreview.textContent = '{{ t('protocols.click_preview_to_see_output') }}';
}
});
// Copy variables
document.querySelectorAll('.copy-variable').forEach(btn => {
btn.addEventListener('click', function() {
const variable = this.dataset.variable;
const textarea = document.createElement('textarea');
textarea.value = variable;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
// Show feedback
const originalText = this.textContent;
this.textContent = '{{ t('common.copied') }}';
setTimeout(() => {
this.textContent = originalText;
}, 1000);
});
});
// Auto-save functionality (optional)
let autoSaveTimeout;
templateEditor.addEventListener('input', function() {
clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(function() {
// Could implement auto-save here
console.log('Template changed, could auto-save...');
}, 2000);
});
});
</script>
{% endblock %}
@@ -0,0 +1,522 @@
<div class="max-w-6xl mx-auto px-1 py-2">
<!-- Header -->
<div class="mb-8">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900">{{ t('protocols.management') }}</h1>
<p class="mt-2 text-gray-600">{{ t('protocols.management_description') }}</p>
</div>
<div class="flex space-x-3">
<button id="ai-assistant-btn" class="inline-flex items-center px-4 py-2 border border-purple-300 rounded-md shadow-sm text-sm font-medium text-purple-700 bg-white hover:bg-purple-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
{{ t('ai.assistant') }}
</button>
<a href="/settings/protocols/new" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{{ t('protocols.add_protocol') }}
</a>
</div>
</div>
</div>
<!-- Success/Error Messages -->
{% if success %}
<div class="mb-4 bg-green-50 border border-green-200 rounded-md p-4">
<div class="flex">
<svg class="w-5 h-5 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<p class="text-green-800">{{ success }}</p>
</div>
</div>
{% endif %}
{% if error %}
<div class="mb-4 bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex">
<svg class="w-5 h-5 text-red-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<p class="text-red-800">{{ error }}</p>
</div>
</div>
{% endif %}
<!-- Protocols Grid -->
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<div class="px-4 py-5 sm:p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-medium text-gray-900">{{ t('protocols.available_protocols') }}</h2>
<div class="flex space-x-2">
<input type="text" id="protocol-search" placeholder="{{ t('protocols.search_protocols') }}" class="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<select id="protocol-filter" class="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">{{ t('protocols.all_protocols') }}</option>
<option value="active">{{ t('protocols.active_only') }}</option>
<option value="ubuntu">{{ t('protocols.ubuntu_compatible') }}</option>
<option value="with-ai">{{ t('protocols.with_ai_generations') }}</option>
</select>
</div>
</div>
<div id="protocols-list" class="space-y-4">
{% for protocol in protocols %}
<div class="protocol-card border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow" data-protocol-id="{{ protocol.id }}" data-protocol-name="{{ protocol.name }}" data-protocol-slug="{{ protocol.slug }}" data-active="{{ protocol.is_active }}" data-ubuntu="{{ protocol.ubuntu_compatible }}" data-ai-generations="{{ protocol.ai_generation_count }}">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center space-x-3">
<h3 class="text-lg font-semibold text-gray-900">{{ protocol.name }}</h3>
{% if protocol.is_active %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{{ t('common.active') }}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ t('common.inactive') }}
</span>
{% endif %}
{% if protocol.ubuntu_compatible %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Ubuntu 22-24
</span>
{% endif %}
{% if protocol.ai_generation_count > 0 %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
AI {{ protocol.ai_generation_count }}
</span>
{% endif %}
</div>
<p class="mt-1 text-sm text-gray-600">{{ protocol.description }}</p>
<div class="mt-2 flex items-center space-x-4 text-xs text-gray-500">
<span>{{ t('common.slug') }}: <code class="bg-gray-100 px-1 rounded">{{ protocol.slug }}</code></span>
<span>{{ t('common.servers') }}: {{ protocol.server_count }}</span>
<span>{{ t('common.templates') }}: {{ protocol.template_count }}</span>
<span>{{ t('common.variables') }}: {{ protocol.variable_count }}</span>
</div>
</div>
<div class="flex space-x-2">
<button class="ai-generate-btn text-purple-600 hover:text-purple-900" data-protocol-id="{{ protocol.id }}" title="{{ t('ai.generate_with_ai') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
</button>
<a href="/settings/protocols/{{ protocol.id }}/edit" class="text-blue-600 hover:text-blue-900" title="{{ t('common.edit') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</a>
<a href="/settings/protocols/{{ protocol.id }}/template" class="text-green-600 hover:text-green-900" title="{{ t('protocols.edit_template') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
</a>
{% if protocol.server_count == 0 %}
<button class="delete-protocol-btn text-red-600 hover:text-red-900" data-protocol-id="{{ protocol.id }}" data-protocol-name="{{ protocol.name }}" title="{{ t('common.delete') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">{{ t('protocols.no_protocols') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ t('protocols.no_protocols_description') }}</p>
<div class="mt-6">
<a href="/settings/protocols/new" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{{ t('protocols.create_first_protocol') }}
</a>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- AI Assistant Modal -->
<div id="ai-assistant-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-4xl shadow-lg rounded-md bg-white">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900">{{ t('ai.assistant') }}</h3>
<button id="close-ai-modal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('ai.select_model') }}</label>
<select id="ai-model-select" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="openai/gpt-3.5-turbo">{{ t('ai.model_gpt35_turbo') }}</option>
<option value="openai/gpt-4">{{ t('ai.model_gpt4') }}</option>
<option value="anthropic/claude-3-haiku">{{ t('ai.model_claude3_haiku') }}</option>
<option value="anthropic/claude-3-sonnet">{{ t('ai.model_claude3_sonnet') }}</option>
</select>
<div class="mt-2 flex items-center space-x-2">
<input id="ai-model-custom" type="text" placeholder="{{ t('ai.custom_model_placeholder') }}" class="flex-1 px-3 py-2 border border-gray-300 rounded-md">
<button id="ai-model-test-btn" type="button" class="px-3 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-50">{{ t('ai.check_availability') }}</button>
</div>
<p class="mt-2 text-xs text-gray-500">
<i class="fas fa-external-link-alt"></i>
<a href="https://openrouter.ai/models" target="_blank" class="text-purple-600">openrouter.ai/models</a>
</p>
{% if not openrouter_key %}
<div class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded">
<div class="flex items-center justify-between">
<span class="text-xs text-yellow-800">{{ t('settings.no_api_key') }}</span>
<a href="/settings#api" class="px-2 py-1 text-xs bg-purple-600 text-white rounded">{{ t('settings.enter_api_key') }}</a>
</div>
</div>
{% endif %}
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('ai.protocol_type') }}</label>
<select id="ai-protocol-type" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">{{ t('ai.general_vpn') }}</option>
<option value="wireguard">WireGuard</option>
<option value="openvpn">OpenVPN</option>
<option value="shadowsocks">Shadowsocks</option>
<option value="cloak">Cloak</option>
<option value="ikev2">IKEv2</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('ai.describe_requirements') }}</label>
<textarea id="ai-prompt" rows="4" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="{{ t('ai.prompt_placeholder') }}"></textarea>
</div>
<div class="mb-4">
<button id="generate-script-btn" class="w-full inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
{{ t('ai.generate_script') }}
</button>
</div>
<div id="ai-loading" class="hidden text-center py-4">
<div class="inline-flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-purple-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ t('ai.generating_script') }}</span>
</div>
</div>
<div id="ai-result" class="hidden">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('ai.generated_script') }}</label>
<div class="bg-gray-900 text-green-400 p-4 rounded-md overflow-x-auto">
<pre id="generated-script" class="text-sm whitespace-pre-wrap"></pre>
</div>
</div>
<div id="ai-suggestions" class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('ai.suggestions') }}</label>
<ul id="suggestions-list" class="list-disc list-inside space-y-1 text-sm text-gray-600"></ul>
</div>
<div class="mb-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('protocols.name') }}</label>
<input id="ai-protocol-name" type="text" placeholder="Protocol Name" class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('protocols.slug') }}</label>
<input id="ai-protocol-slug" type="text" placeholder="protocol-slug" class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t('common.compatibility') }}</label>
<div id="ubuntu-compatibility" class="flex items-center"></div>
</div>
<div class="flex space-x-3">
<button id="apply-to-protocol-btn" class="flex-1 inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
{{ t('ai.apply_to_protocol') }}
</button>
<button id="create-new-protocol-btn" class="flex-1 inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
{{ t('ai.create_new_protocol') }}
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Protocol search and filter
const searchInput = document.getElementById('protocol-search');
const filterSelect = document.getElementById('protocol-filter');
const protocolCards = document.querySelectorAll('.protocol-card');
function filterProtocols() {
const searchTerm = searchInput.value.toLowerCase();
const filterValue = filterSelect.value;
protocolCards.forEach(card => {
const name = card.dataset.protocolName.toLowerCase();
const slug = card.dataset.protocolSlug.toLowerCase();
const isActive = card.dataset.active === '1';
const isUbuntu = card.dataset.ubuntu === '1';
const hasAI = parseInt(card.dataset.aiGenerations) > 0;
let show = true;
// Search filter
if (searchTerm && !name.includes(searchTerm) && !slug.includes(searchTerm)) {
show = false;
}
// Filter by type
if (filterValue === 'active' && !isActive) show = false;
if (filterValue === 'ubuntu' && !isUbuntu) show = false;
if (filterValue === 'with-ai' && !hasAI) show = false;
card.style.display = show ? 'block' : 'none';
});
}
searchInput.addEventListener('input', filterProtocols);
filterSelect.addEventListener('change', filterProtocols);
// AI Assistant Modal
const aiModal = document.getElementById('ai-assistant-modal');
const aiAssistantBtn = document.getElementById('ai-assistant-btn');
const closeAIModal = document.getElementById('close-ai-modal');
const generateScriptBtn = document.getElementById('generate-script-btn');
const aiLoading = document.getElementById('ai-loading');
const aiResult = document.getElementById('ai-result');
const applyToProtocolBtn = document.getElementById('apply-to-protocol-btn');
const createNewProtocolBtn = document.getElementById('create-new-protocol-btn');
let currentGeneration = null;
let currentProtocolId = null;
function showAIModal() {
aiModal.classList.remove('hidden');
aiResult.classList.add('hidden');
aiLoading.classList.add('hidden');
}
function hideAIModal() {
aiModal.classList.add('hidden');
currentGeneration = null;
currentProtocolId = null;
}
aiAssistantBtn.addEventListener('click', showAIModal);
closeAIModal.addEventListener('click', hideAIModal);
// Close modal when clicking outside
aiModal.addEventListener('click', function(e) {
if (e.target === aiModal) {
hideAIModal();
}
});
// Generate script with AI
generateScriptBtn.addEventListener('click', async function() {
const model = document.getElementById('ai-model-select').value;
const customModel = document.getElementById('ai-model-custom').value.trim();
const effectiveModel = customModel !== '' ? customModel : model;
const protocolType = document.getElementById('ai-protocol-type').value;
const prompt = document.getElementById('ai-prompt').value;
if (!prompt.trim()) {
alert('{{ t('ai.please_enter_requirements') }}');
return;
}
aiLoading.classList.remove('hidden');
generateScriptBtn.disabled = true;
try {
const response = await fetch('/api/ai/assist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: prompt,
model: effectiveModel,
protocol_type: protocolType,
protocol_id: currentProtocolId
})
});
const result = await response.json();
if (result.success) {
currentGeneration = result.data;
displayAIResult(result.data);
} else {
alert('{{ t('ai.error_generating_script') }}: ' + result.error);
}
} catch (error) {
alert('{{ t('ai.error_generating_script') }}: ' + error.message);
} finally {
aiLoading.classList.add('hidden');
generateScriptBtn.disabled = false;
}
});
function displayAIResult(data) {
document.getElementById('generated-script').textContent = data.script;
const suggestionsList = document.getElementById('suggestions-list');
suggestionsList.innerHTML = '';
data.suggestions.forEach(suggestion => {
const li = document.createElement('li');
li.textContent = suggestion;
suggestionsList.appendChild(li);
});
const compatibilityDiv = document.getElementById('ubuntu-compatibility');
if (data.ubuntu_compatible) {
compatibilityDiv.innerHTML = '<svg class="w-5 h-5 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg><span class="text-green-700">Compatible with Ubuntu 22.04-24.04</span>';
} else {
compatibilityDiv.innerHTML = '<svg class="w-5 h-5 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg><span class="text-red-700">May not be compatible with Ubuntu 22.04-24.04</span>';
}
aiResult.classList.remove('hidden');
}
// Apply to existing protocol
applyToProtocolBtn.addEventListener('click', function() {
if (!currentGeneration) return;
const protocolId = prompt('{{ t('ai.enter_protocol_id_to_apply') }}:');
if (!protocolId) return;
fetch(`/api/protocols/${protocolId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
install_script: currentGeneration.script,
ubuntu_compatible: currentGeneration.ubuntu_compatible
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert('{{ t('ai.script_applied_successfully') }}');
location.reload();
} else {
alert('{{ t('ai.error_applying_script') }}: ' + result.error);
}
})
.catch(error => {
alert('{{ t('ai.error_applying_script') }}: ' + error.message);
});
});
// Create new protocol with generated script
createNewProtocolBtn.addEventListener('click', function() {
if (!currentGeneration) return;
const nameInput = document.getElementById('ai-protocol-name');
const slugInput = document.getElementById('ai-protocol-slug');
const name = (nameInput.value || '').trim();
let slug = (slugInput.value || '').trim();
if (!name) { alert('{{ t('protocols.enter_protocol_name') }}'); return; }
if (!slug) { slug = name.toLowerCase().replace(/\s+/g, '-'); }
fetch('/api/protocols', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
slug: slug,
install_script: currentGeneration.script,
ubuntu_compatible: currentGeneration.ubuntu_compatible,
description: `Generated with AI using ${document.getElementById('ai-model-select').value}`
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
alert('{{ t('protocols.protocol_created_successfully') }}');
location.reload();
} else {
alert('{{ t('protocols.error_creating_protocol') }}: ' + result.error);
}
})
.catch(error => {
alert('{{ t('protocols.error_creating_protocol') }}: ' + error.message);
});
});
// AI generate for specific protocol
document.querySelectorAll('.ai-generate-btn').forEach(btn => {
btn.addEventListener('click', function() {
currentProtocolId = this.dataset.protocolId;
showAIModal();
document.getElementById('ai-prompt').value = `{{ t('ai.improve_protocol') }} ${this.closest('.protocol-card').dataset.protocolName}`;
});
});
// Test custom model availability
document.getElementById('ai-model-test-btn').addEventListener('click', async function() {
const customModel = document.getElementById('ai-model-custom').value.trim();
if (!customModel) { alert('Введите идентификатор модели'); return; }
try {
const resp = await fetch('/api/ai/test-model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: customModel })
});
const result = await resp.json();
if (result.success) {
alert('Модель доступна');
} else {
alert('Модель недоступна: ' + (result.error || result.message || ''));
}
} catch (e) {
alert('Ошибка проверки модели: ' + e.message);
}
});
// Delete protocol
document.querySelectorAll('.delete-protocol-btn').forEach(btn => {
btn.addEventListener('click', function() {
const protocolId = this.dataset.protocolId;
const protocolName = this.dataset.protocolName;
if (confirm(`{{ t('protocols.confirm_delete_protocol') }} '${protocolName}'?`)) {
fetch(`/api/protocols/${protocolId}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(result => {
if (result.success) {
location.reload();
} else {
alert('{{ t('protocols.error_deleting_protocol') }}: ' + result.error);
}
})
.catch(error => {
alert('{{ t('protocols.error_deleting_protocol') }}: ' + error.message);
});
}
});
});
});
</script>
+250
View File
@@ -0,0 +1,250 @@
{% extends "layout.twig" %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-10 offset-md-1">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
{% if scenario %}
{{ 'Редактирование сценария:' | trans }} {{ scenario.name }}
{% else %}
{{ 'Новый сценарий' | trans }}
{% endif %}
</h5>
</div>
<div class="card-body">
<form id="scenarioForm">
<input type="hidden" name="id" value="{{ scenario.id | default('') }}">
<div class="mb-3">
<label for="slug" class="form-label">{{ 'Уникальный идентификатор' | trans }} *</label>
<input type="text" class="form-control" id="slug" name="slug"
value="{{ scenario.slug | default('') }}" required
pattern="^[a-z0-9\-]+$" title="{{ 'Только строчные буквы, цифры и дефисы' | trans }}">
<small class="form-text text-muted">{{ 'например: xray-vless, openvpn-tls' | trans }}</small>
</div>
<div class="mb-3">
<label for="name" class="form-label">{{ 'Название протокола' | trans }} *</label>
<input type="text" class="form-control" id="name" name="name"
value="{{ scenario.name | default('') }}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">{{ 'Описание' | trans }}</label>
<textarea class="form-control" id="description" name="description" rows="2">{{ scenario.description | default('') }}</textarea>
</div>
<div class="mb-3">
<label for="definition" class="form-label">{{ 'Определение сценария (JSON)' | trans }} *</label>
<textarea class="form-control font-monospace" id="definition" name="definition"
rows="20" required>{{ templateDefinition }}</textarea>
<small class="form-text text-muted d-block mt-2">
<strong>{{ 'Структура JSON:' | trans }}</strong><br>
<code>{ "engine": "shell|builtin_awg", "metadata": {...}, "scripts": { "detect": "...", "install": "...", "restore": "..." } }</code>
</small>
<small class="form-text text-muted d-block mt-2">
<strong>{{ 'Доступные переменные в скриптах:' | trans }}</strong><br>
<code>{{ "{{server.host}}, {{server.username}}, {{server.container_name}}, {{metadata.*}}" | trans }}</code>
</small>
<div id="jsonError" class="alert alert-danger mt-2" style="display: none;"></div>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="is_active" name="is_active" value="1"
{% if scenario.is_active ?? true %}checked{% endif %}>
<label class="form-check-label" for="is_active">
{{ 'Активный сценарий' | trans }}
</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> {{ 'Сохранить' | trans }}
</button>
<a href="/admin/scenarios" class="btn btn-secondary">
{{ 'Отмена' | trans }}
</a>
{% if scenario %}
<button type="button" class="btn btn-info ms-auto" id="testBtn">
<i class="fas fa-flask"></i> {{ 'Тест на сервере' | trans }}
</button>
{% endif %}
</div>
</form>
</div>
</div>
<!-- JSON Validation Helper -->
<div class="card mt-3">
<div class="card-header bg-light">
<h6 class="mb-0">{{ 'Справка по формату' | trans }}</h6>
</div>
<div class="card-body">
<h6>{{ 'Поля сценария:' | trans }}</h6>
<ul>
<li><strong>engine:</strong> Тип движка ("shell" или "builtin_awg")</li>
<li><strong>metadata:</strong> Объект с параметрами протокола (container_name, config_path и т.д.)</li>
<li><strong>scripts:</strong> Объект со скриптами (detect, install, restore)</li>
</ul>
<h6 class="mt-3">{{ 'Поля скриптов:' | trans }}</h6>
<ul>
<li><strong>detect:</strong> Bash скрипт для определения установленной конфигурации. Должен вывести JSON с полями "status" (absent/partial/existing) и "details"</li>
<li><strong>install:</strong> Bash скрипт для установки протокола. Должен вывести JSON с "success": true/false</li>
<li><strong>restore:</strong> Bash скрипт для восстановления конфигурации из detection результата</li>
</ul>
<h6 class="mt-3">{{ 'Переменные окружения в скриптах:' | trans }}</h6>
<ul>
<li><code>SERVER_HOST</code> - IP/домен сервера</li>
<li><code>SERVER_USER</code> - SSH пользователь</li>
<li><code>SERVER_CONTAINER</code> - имя контейнера</li>
<li><code>PROTOCOL_*</code> - все поля из metadata (например, PROTOCOL_CONTAINER_NAME, PROTOCOL_CONFIG_PATH)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Test Modal -->
<div class="modal fade" id="testModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ 'Тест сценария' | trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="testServer" class="form-label">{{ 'Выбрать сервер' | trans }}</label>
<select class="form-control" id="testServer">
<option value="">{{ 'Загружаю...' | trans }}</option>
</select>
</div>
<div id="testResult" class="mt-3" style="display: none;">
<strong>{{ 'Результат:' | trans }}</strong>
<pre id="testResultContent" class="bg-light p-3 rounded" style="max-height: 300px; overflow-y: auto;"></pre>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Закрыть' | trans }}</button>
<button type="button" class="btn btn-primary" id="runTestBtn">{{ 'Запустить тест' | trans }}</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('scenarioForm');
const definitionTextarea = document.getElementById('definition');
const jsonError = document.getElementById('jsonError');
const testBtn = document.getElementById('testBtn');
// Validate JSON on change
definitionTextarea.addEventListener('change', validateJson);
definitionTextarea.addEventListener('blur', validateJson);
function validateJson() {
jsonError.style.display = 'none';
try {
JSON.parse(definitionTextarea.value);
} catch (e) {
jsonError.textContent = `{{ "Ошибка JSON:" | trans }} ${e.message}`;
jsonError.style.display = 'block';
}
}
// Form submission
form.addEventListener('submit', async function(e) {
e.preventDefault();
if (!validateJson()) return;
const formData = new FormData(this);
try {
const response = await fetch('/admin/scenario', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
alert('{{ "Сценарий успешно сохранен" | trans }}');
window.location.href = data.redirect;
} else {
alert(`{{ "Ошибка:" | trans }} ${data.message}`);
}
} catch (err) {
alert(`{{ "Ошибка отправки:" | trans }} ${err}`);
}
});
// Test button
if (testBtn) {
testBtn.addEventListener('click', function() {
loadServers();
new bootstrap.Modal(document.getElementById('testModal')).show();
});
}
// Load available servers
async function loadServers() {
try {
const response = await fetch('/api/servers?limit=50');
const data = await response.json();
const select = document.getElementById('testServer');
select.innerHTML = '';
if (data.servers && data.servers.length > 0) {
data.servers.forEach(server => {
const option = document.createElement('option');
option.value = server.id;
option.textContent = `${server.name} (${server.host})`;
select.appendChild(option);
});
} else {
select.innerHTML = '<option value="">{{ "Сервера не найдены" | trans }}</option>';
}
} catch (err) {
console.error('Error loading servers:', err);
}
}
// Run test
document.getElementById('runTestBtn').addEventListener('click', async function() {
const serverId = document.getElementById('testServer').value;
if (!serverId) {
alert('{{ "Выберите сервер" | trans }}');
return;
}
const scenarioId = document.querySelector('input[name="id"]').value;
try {
const response = await fetch(`/admin/scenario/${scenarioId}/test`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `server_id=${serverId}`
});
const data = await response.json();
const resultDiv = document.getElementById('testResult');
const resultContent = document.getElementById('testResultContent');
resultContent.textContent = JSON.stringify(data.result, null, 2);
resultDiv.style.display = 'block';
} catch (err) {
alert(`{{ "Ошибка теста:" | trans }} ${err}`);
}
});
});
</script>
{% endblock %}
+246
View File
@@ -0,0 +1,246 @@
{% extends "layout.twig" %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1>{{ scenario.name }}</h1>
<small class="text-muted">{{ scenario.slug }}</small>
</div>
<div class="btn-group">
<a href="/admin/scenario/{{ scenario.id }}/edit" class="btn btn-primary">
<i class="fas fa-edit"></i> {{ 'Редактировать' | trans }}
</a>
<a href="/admin/scenario/{{ scenario.id }}/export" class="btn btn-secondary">
<i class="fas fa-download"></i> {{ 'Экспорт' | trans }}
</a>
<a href="/admin/scenarios" class="btn btn-light">
<i class="fas fa-arrow-left"></i> {{ 'Назад' | trans }}
</a>
</div>
</div>
<!-- Scenario Info -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">{{ 'Информация о сценарии' | trans }}</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<dl>
<dt>{{ 'Статус' | trans }}</dt>
<dd>
{% if scenario.is_active %}
<span class="badge bg-success">{{ 'Активный' | trans }}</span>
{% else %}
<span class="badge bg-secondary">{{ 'Отключен' | trans }}</span>
{% endif %}
</dd>
<dt>{{ 'Движок' | trans }}</dt>
<dd><code>{{ definition.engine | default('unknown') }}</code></dd>
<dt>{{ 'Описание' | trans }}</dt>
<dd>{{ scenario.description | default('—') }}</dd>
</dl>
</div>
<div class="col-md-6">
<dl>
<dt>{{ 'Контейнер' | trans }}</dt>
<dd><code>{{ definition.metadata.container_name | default('—') }}</code></dd>
<dt>{{ 'Путь конфигурации' | trans }}</dt>
<dd><code>{{ definition.metadata.config_path | default('—') }}</code></dd>
<dt>{{ 'Порт по умолчанию' | trans }}</dt>
<dd>{{ definition.metadata.default_port | default('—') }}</dd>
</dl>
</div>
</div>
</div>
</div>
<!-- Scripts Info -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">{{ 'Скрипты' | trans }}</h5>
</div>
<div class="card-body">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="detect-tab" data-bs-toggle="tab" data-bs-target="#detect-content" type="button" role="tab">
{{ 'Обнаружение (detect)' | trans }}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="install-tab" data-bs-toggle="tab" data-bs-target="#install-content" type="button" role="tab">
{{ 'Установка (install)' | trans }}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="restore-tab" data-bs-toggle="tab" data-bs-target="#restore-content" type="button" role="tab">
{{ 'Восстановление (restore)' | trans }}
</button>
</li>
</ul>
<div class="tab-content mt-3">
<div class="tab-pane fade show active" id="detect-content" role="tabpanel">
<pre class="bg-light p-3 rounded"><code>{{ definition.scripts.detect | default('—') }}</code></pre>
</div>
<div class="tab-pane fade" id="install-content" role="tabpanel">
<pre class="bg-light p-3 rounded"><code>{{ definition.scripts.install | default('—') }}</code></pre>
</div>
<div class="tab-pane fade" id="restore-content" role="tabpanel">
<pre class="bg-light p-3 rounded"><code>{{ definition.scripts.restore | default('—') }}</code></pre>
</div>
</div>
</div>
</div>
<!-- Metadata -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">{{ 'Метаданные' | trans }}</h5>
</div>
<div class="card-body">
<pre class="bg-light p-3 rounded"><code>{{ definition.metadata | json_encode(constant('JSON_PRETTY_PRINT') | constant('JSON_UNESCAPED_SLASHES')) }}</code></pre>
</div>
</div>
<!-- Actions -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ 'Действия' | trans }}</h5>
</div>
<div class="card-body">
<button class="btn btn-info" id="testScenarioBtn">
<i class="fas fa-flask"></i> {{ 'Тест на сервере' | trans }}
</button>
{% if scenario.slug != 'amnezia-wg' %}
<button class="btn btn-danger" id="deleteScenarioBtn">
<i class="fas fa-trash"></i> {{ 'Удалить сценарий' | trans }}
</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Test Modal -->
<div class="modal fade" id="testModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ 'Тест сценария' | trans }} - {{ scenario.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="testServer" class="form-label">{{ 'Выбрать сервер' | trans }}</label>
<select class="form-control" id="testServer">
<option value="">{{ 'Загружаю...' | trans }}</option>
</select>
</div>
<div id="testResult" class="mt-3" style="display: none;">
<strong>{{ 'Результат:' | trans }}</strong>
<pre id="testResultContent" class="bg-light p-3 rounded" style="max-height: 400px; overflow-y: auto;"></pre>
</div>
<div id="testError" class="alert alert-danger mt-3" style="display: none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Закрыть' | trans }}</button>
<button type="button" class="btn btn-primary" id="runTestBtn">{{ 'Запустить тест' | trans }}</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const scenarioId = {{ scenario.id }};
// Test button
document.getElementById('testScenarioBtn').addEventListener('click', function() {
loadServers();
new bootstrap.Modal(document.getElementById('testModal')).show();
});
// Delete button
const deleteBtn = document.getElementById('deleteScenarioBtn');
if (deleteBtn) {
deleteBtn.addEventListener('click', function() {
if (confirm('{{ "Вы уверены? Это действие нельзя отменить." | trans }}')) {
fetch(`/admin/scenario/${scenarioId}/delete`, {method: 'POST'})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = data.redirect;
} else {
alert(`{{ "Ошибка:" | trans }} ${data.message}`);
}
});
}
});
}
// Load servers
async function loadServers() {
try {
const response = await fetch('/api/servers?limit=50');
const data = await response.json();
const select = document.getElementById('testServer');
select.innerHTML = '';
if (data.servers && data.servers.length > 0) {
data.servers.forEach(server => {
const option = document.createElement('option');
option.value = server.id;
option.textContent = `${server.name} (${server.host})`;
select.appendChild(option);
});
}
} catch (err) {
console.error('Error loading servers:', err);
}
}
// Run test
document.getElementById('runTestBtn').addEventListener('click', async function() {
const serverId = document.getElementById('testServer').value;
if (!serverId) {
alert('{{ "Выберите сервер" | trans }}');
return;
}
try {
const response = await fetch(`/admin/scenario/${scenarioId}/test`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `server_id=${serverId}`
});
const data = await response.json();
const resultDiv = document.getElementById('testResult');
const resultContent = document.getElementById('testResultContent');
const errorDiv = document.getElementById('testError');
errorDiv.style.display = 'none';
if (data.success) {
resultContent.textContent = JSON.stringify(data.result, null, 2);
resultDiv.style.display = 'block';
} else {
errorDiv.textContent = data.message;
errorDiv.style.display = 'block';
}
} catch (err) {
const errorDiv = document.getElementById('testError');
errorDiv.textContent = `{{ "Ошибка:" | trans }} ${err}`;
errorDiv.style.display = 'block';
}
});
});
</script>
{% endblock %}
+156
View File
@@ -0,0 +1,156 @@
{% extends "layout.twig" %}
{% block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>{{ 'Сценарии установки протоколов' | trans }}</h1>
<div class="btn-group">
<a href="/admin/scenario/create" class="btn btn-primary">
<i class="fas fa-plus"></i> {{ 'Новый сценарий' | trans }}
</a>
<button class="btn btn-success" id="btnImport">
<i class="fas fa-upload"></i> {{ 'Импорт' | trans }}
</button>
</div>
</div>
{% if scenarios | length > 0 %}
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead class="table-light">
<tr>
<th>{{ 'Протокол' | trans }}</th>
<th>{{ 'Описание' | trans }}</th>
<th>{{ 'Движок' | trans }}</th>
<th>{{ 'Статус' | trans }}</th>
<th>{{ 'Действия' | trans }}</th>
</tr>
</thead>
<tbody>
{% for scenario in scenarios %}
<tr>
<td>
<strong>{{ scenario.name }}</strong>
<br>
<small class="text-muted">{{ scenario.slug }}</small>
</td>
<td>{{ scenario.description | default('-') }}</td>
<td>
<span class="badge bg-info">
{{ scenario.definition.engine | default('unknown') }}
</span>
</td>
<td>
{% if scenario.is_active %}
<span class="badge bg-success">{{ 'Активный' | trans }}</span>
{% else %}
<span class="badge bg-secondary">{{ 'Отключен' | trans }}</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="/admin/scenario/{{ scenario.id }}" class="btn btn-info" title="{{ 'Просмотр' | trans }}">
<i class="fas fa-eye"></i>
</a>
<a href="/admin/scenario/{{ scenario.id }}/edit" class="btn btn-warning" title="{{ 'Редактировать' | trans }}">
<i class="fas fa-edit"></i>
</a>
<a href="/admin/scenario/{{ scenario.id }}/export" class="btn btn-secondary" title="{{ 'Экспорт' | trans }}">
<i class="fas fa-download"></i>
</a>
{% if scenario.slug != 'amnezia-wg' %}
<button class="btn btn-danger delete-scenario" data-id="{{ scenario.id }}" data-name="{{ scenario.name }}" title="{{ 'Удалить' | trans }}">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info" role="alert">
{{ 'Нет доступных сценариев' | trans }}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Import Modal -->
<div class="modal fade" id="importModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ 'Импорт сценария' | trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="importForm" enctype="multipart/form-data">
<div class="modal-body">
<div class="mb-3">
<label for="importFile" class="form-label">{{ 'JSON файл сценария' | trans }}</label>
<input type="file" class="form-control" id="importFile" name="file" accept=".json" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Отмена' | trans }}</button>
<button type="submit" class="btn btn-primary">{{ 'Импортировать' | trans }}</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Import button handler
document.getElementById('btnImport').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('importModal')).show();
});
// Import form handler
document.getElementById('importForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch('/admin/scenario/import', {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = data.redirect;
} else {
alert('{{ "Ошибка импорта:" | trans }} ' + data.message);
}
})
.catch(err => alert('{{ "Ошибка:" | trans }} ' + err));
});
// Delete buttons
document.querySelectorAll('.delete-scenario').forEach(btn => {
btn.addEventListener('click', function() {
const id = this.dataset.id;
const name = this.dataset.name;
if (confirm(`{{ "Удалить сценарий" | trans }}: ${name}?`)) {
fetch(`/admin/scenario/${id}/delete`, {method: 'POST'})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = data.redirect;
} else {
alert('{{ "Ошибка удаления:" | trans }} ' + data.message);
}
});
}
});
});
});
</script>
{% endblock %}
+153
View File
@@ -0,0 +1,153 @@
{% extends "layout.twig" %}
{% block title %}QR Decode{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-6">QR Decode</h1>
<div class="bg-white rounded shadow p-6 mb-6">
<h3 class="font-bold mb-4">Upload image</h3>
<input id="fileInput" type="file" accept="image/*" class="mb-4">
<canvas id="canvas" style="display:none"></canvas>
<div id="imagePreview" class="mb-4"></div>
<button id="decodeBtn" class="gradient-bg text-white px-4 py-2 rounded" onclick="decodeClick()">Decode</button>
</div>
<div class="bg-white rounded shadow p-6 mb-6">
<h3 class="font-bold mb-4">Paste payload</h3>
<textarea id="payloadInput" class="w-full border rounded p-2 mb-3" rows="4" placeholder="vless://... or base64url"></textarea>
<button id="parseBtn" class="bg-blue-600 text-white px-4 py-2 rounded">Parse</button>
</div>
<div class="bg-white rounded shadow p-6">
<h3 class="font-bold mb-4">Result</h3>
<pre id="result" class="text-sm whitespace-pre-wrap"></pre>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pako@2.1.0/dist/pako.min.js"></script>
<script src="https://unpkg.com/@zxing/library@0.18.6/umd/index.min.js"></script>
<script>
const fileInput = document.getElementById('fileInput');
const decodeBtn = document.getElementById('decodeBtn');
const canvas = document.getElementById('canvas');
const preview = document.getElementById('imagePreview');
const result = document.getElementById('result');
const payloadInput = document.getElementById('payloadInput');
const parseBtn = document.getElementById('parseBtn');
fileInput.addEventListener('change', () => {
const f = fileInput.files[0];
if (!f) return;
const url = URL.createObjectURL(f);
const img = new Image();
img.onload = () => {
preview.innerHTML = '';
preview.appendChild(img);
};
img.src = url;
});
function b64urlToUint8(b64url) {
let s = b64url.replace(/-/g,'+').replace(/_/g,'/');
while (s.length % 4) s += '=';
const bin = atob(s);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
function readBE32(arr, off) {
return (arr[off]<<24) | (arr[off+1]<<16) | (arr[off+2]<<8) | (arr[off+3]);
}
function parseAwgPayload(b64url) {
const bytes = b64urlToUint8(b64url);
if (bytes.length < 12) throw new Error('short payload');
const version = readBE32(bytes,0) >>> 0;
const compLen = readBE32(bytes,4) >>> 0;
const uncompLen = readBE32(bytes,8) >>> 0;
const data = bytes.slice(12);
const json = pako.inflate(data, {to:'string'});
const obj = JSON.parse(json);
return {version, compLen, uncompLen, json, obj};
}
function parseVless(uri) {
const u = new URL(uri);
const out = {
scheme: u.protocol.replace(':',''),
host: u.hostname,
port: u.port,
user: decodeURIComponent(u.username),
params: {}
};
const qs = u.searchParams;
qs.forEach((v,k)=>{ out.params[k]=v; });
return out;
}
function show(obj) {
result.textContent = typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2);
}
async function decodeClick() {
const f = fileInput.files[0];
if (!f) { alert('Choose image'); return; }
let img = preview.querySelector('img');
if (!img || !img.complete) {
const url = URL.createObjectURL(f);
img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = url;
});
preview.innerHTML = '';
preview.appendChild(img);
}
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
if (!w || !h) { show('Image has zero size'); return; }
const scale = Math.min(1, 1200 / Math.max(w, h));
canvas.width = Math.max(1, Math.floor(w * scale));
canvas.height = Math.max(1, Math.floor(h * scale));
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0,0,canvas.width,canvas.height);
let text = '';
try {
const code = jsQR(imageData.data, imageData.width, imageData.height);
text = (code && code.data) ? code.data : '';
} catch (e) { text = ''; }
if (!text && window.ZXing && ZXing.BrowserQRCodeReader) {
try {
const reader = new ZXing.BrowserQRCodeReader();
const res = await reader.decodeFromImage(img);
text = res && res.text ? res.text : '';
} catch (e) { /* ignore */ }
}
if (!text) { show('No QR found'); return; }
payloadInput.value = text;
try {
if (text.startsWith('vless://')) {
show(parseVless(text));
} else {
show(parseAwgPayload(text));
}
} catch (e) { show('Parse error: ' + e.message); }
}
decodeBtn.addEventListener('click', decodeClick);
parseBtn.addEventListener('click', () => {
const text = payloadInput.value.trim();
if (!text) { show('Empty'); return; }
try {
if (text.startsWith('vless://')) {
show(parseVless(text));
} else {
show(parseAwgPayload(text));
}
} catch (e) { show('Parse error: ' + e.message); }
});
</script>
{% endblock %}