feat: ssh auth, protocol management, and cleanup
This commit is contained in:
+182
-33
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user