Add Amnezia Web Panel source with PostgreSQL 17 storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 00:02:18 +03:00
co-authored by Cursor
parent ead1c64dd1
commit 8b70d51c87
50 changed files with 21713 additions and 0 deletions
+453
View File
@@ -0,0 +1,453 @@
{% extends "base.html" %}
{% block content %}
<section>
<div class="flex items-center justify-between" style="margin-bottom: var(--space-lg);">
<h1 class="section-title" style="margin-bottom:0;">
<span class="icon">🖥</span>
{{ _('nav_servers') }}
</h1>
<button class="btn btn-primary" onclick="openModal('addServerModal')">
<span></span> {{ _('add_server') }}
</button>
</div>
{% if servers %}
<div class="server-grid" id="serverGrid">
{% for server in servers %}
<div class="card card-hover server-card" id="server-{{ loop.index0 }}" data-idx="{{ loop.index0 }}"
data-name="{{ server.name }}" data-host="{{ server.host }}" data-port="{{ server.ssh_port }}"
data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}"
draggable="true">
<div class="server-meta">
<div class="server-icon">🖥</div>
<div style="min-width: 0; flex: 1;">
<div class="server-name">
<span class="ping-dot ping-pending" id="ping-{{ loop.index0 }}"
title="{{ _('ping_checking') }}"></span>
<span>{{ server.name }}</span>
<span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span>
</div>
<div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div>
</div>
</div>
<div class="server-protocols" id="server-protocols-{{ loop.index0 }}">
{% if server.protocols %}
{% for proto_key, proto_val in server.protocols.items() %}
{% if proto_val.get('installed') %}
<span class="badge badge-success">
<span class="badge-dot"></span>
{{ 'AWG' if proto_key == 'awg' else ('AWG-Legacy' if proto_key == 'awg_legacy' else ('Xray' if
proto_key == 'xray' else proto_key | upper)) }}
</span>
{% endif %}
{% endfor %}
{% else %}
<span class="badge badge-warn">
<span class="badge-dot"></span>
{{ _('no_protocols') }}
</span>
{% endif %}
</div>
<div class="server-actions">
<a href="/server/{{ loop.index0 }}" class="btn btn-secondary btn-sm" style="flex:1">
{{ _('manage') }}
</a>
<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditServer(this)"
title="{{ _('edit') }}">
✏️
</button>
<button class="btn btn-danger btn-sm btn-icon" onclick="deleteServer({{ loop.index0 }})"
title="{{ _('delete') }}">
🗑
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🛡</div>
<div class="empty-title">{{ _('no_servers') }}</div>
<div class="empty-desc">
{{ _('add_server_desc') }}
</div>
<button class="btn btn-primary" onclick="openModal('addServerModal')">
<span></span> {{ _('add_server') }}
</button>
</div>
{% endif %}
</section>
<!-- ===== Edit Server Modal ===== -->
<div class="modal-backdrop" id="editServerModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">{{ _('edit_server_title') }}</h2>
<button class="modal-close" onclick="closeModal('editServerModal')">×</button>
</div>
<form id="editServerForm" onsubmit="return submitEditServer(event)">
<input type="hidden" id="editServerIdx">
<div class="form-group">
<label class="form-label">{{ _('name') }}</label>
<input class="form-input" type="text" id="editServerName">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ _('ssh_host') }}</label>
<input class="form-input" type="text" id="editServerHost" required>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_port') }}</label>
<input class="form-input" type="number" id="editServerPort" min="1" max="65535">
</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_user') }}</label>
<input class="form-input" type="text" id="editServerUser" required>
</div>
<div class="tabs" style="margin-top: var(--space-md)">
<button type="button" class="tab active" onclick="switchEditAuthTab('password', this)">{{ _('ssh_password') }}</button>
<button type="button" class="tab" onclick="switchEditAuthTab('key', this)">{{ _('ssh_key') }}</button>
</div>
<div id="editAuthPassword">
<div class="form-group">
<label class="form-label">{{ _('ssh_password') }}</label>
<input class="form-input" type="password" id="editServerPassword"
placeholder="{{ _('edit_keep_credential') }}">
<div class="form-hint">{{ _('edit_keep_credential') }}</div>
</div>
</div>
<div id="editAuthKey" class="hidden">
<div class="form-group">
<label class="form-label">{{ _('ssh_key') }}</label>
<textarea class="form-textarea" id="editServerKey"
placeholder="{{ _('edit_keep_credential') }}"></textarea>
<div class="form-hint">{{ _('edit_keep_credential') }}</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('editServerModal')">{{ _('cancel')
}}</button>
<button type="submit" class="btn btn-primary" id="editServerBtn">
<span id="editServerBtnText">{{ _('save') }}</span>
<div class="spinner hidden" id="editServerSpinner"></div>
</button>
</div>
</form>
</div>
</div>
<!-- ===== Add Server Modal ===== -->
<div class="modal-backdrop" id="addServerModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">{{ _('add_server') }}</h2>
<button class="modal-close" onclick="closeModal('addServerModal')">×</button>
</div>
<form id="addServerForm" onsubmit="return addServer(event)">
<div class="form-group">
<label class="form-label">{{ _('name') }}</label>
<input class="form-input" type="text" id="serverName" placeholder="My VPN Server">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ _('ssh_host') }}</label>
<input class="form-input" type="text" id="serverHost" placeholder="192.168.1.1" required>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_port') }}</label>
<input class="form-input" type="number" id="serverPort" value="22" min="1" max="65535">
</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_user') }}</label>
<input class="form-input" type="text" id="serverUser" placeholder="root" required>
</div>
<!-- Tabs for auth method -->
<div class="tabs" style="margin-top: var(--space-md)">
<button type="button" class="tab active" onclick="switchAuthTab('password', this)">{{ _('ssh_password')
}}</button>
<button type="button" class="tab" onclick="switchAuthTab('key', this)">{{ _('ssh_key') }}</button>
</div>
<div id="authPassword">
<div class="form-group">
<label class="form-label">{{ _('ssh_password') }}</label>
<input class="form-input" type="password" id="serverPassword" placeholder="••••••••">
</div>
</div>
<div id="authKey" class="hidden">
<div class="form-group">
<label class="form-label">{{ _('ssh_key') }}</label>
<textarea class="form-textarea" id="serverKey"
placeholder="{{ _('ssh_key_placeholder') }}"></textarea>
<div class="form-hint">{{ _('ssh_key_hint') }}</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('addServerModal')">{{ _('cancel')
}}</button>
<button type="submit" class="btn btn-primary" id="addServerBtn">
<span id="addServerBtnText">{{ _('connect') }}</span>
<div class="spinner hidden" id="addServerSpinner"></div>
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function switchAuthTab(tab, btn) {
document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
document.getElementById('authPassword').classList.toggle('hidden', tab !== 'password');
document.getElementById('authKey').classList.toggle('hidden', tab !== 'key');
}
async function addServer(e) {
e.preventDefault();
const btn = document.getElementById('addServerBtn');
const btnText = document.getElementById('addServerBtnText');
const spinner = document.getElementById('addServerSpinner');
btn.disabled = true;
btnText.textContent = _('connecting');
spinner.classList.remove('hidden');
try {
const data = {
name: document.getElementById('serverName').value,
host: document.getElementById('serverHost').value,
ssh_port: parseInt(document.getElementById('serverPort').value) || 22,
username: document.getElementById('serverUser').value,
password: document.getElementById('serverPassword').value,
private_key: document.getElementById('serverKey').value,
};
const result = await apiCall('/api/servers/add', 'POST', data);
showToast(_('server_added'), 'success');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
btnText.textContent = _('connect');
spinner.classList.add('hidden');
}
}
async function deleteServer(serverId) {
if (!confirm(_('delete_confirm'))) return;
try {
await apiCall(`/api/servers/${serverId}/delete`, 'POST');
showToast(_('server_deleted'), 'success');
setTimeout(() => window.location.reload(), 500);
} catch (err) {
showToast(err.message, 'error');
}
}
/* ========== Edit server ========== */
function switchEditAuthTab(tab, btn) {
const tabs = btn.parentElement.querySelectorAll('.tab');
tabs.forEach(t => t.classList.remove('active'));
btn.classList.add('active');
document.getElementById('editAuthPassword').classList.toggle('hidden', tab !== 'password');
document.getElementById('editAuthKey').classList.toggle('hidden', tab !== 'key');
}
function openEditServer(btn) {
// Prefill from data-* on the parent server-card so we don't have to round-trip.
const card = btn.closest('.server-card');
if (!card) return;
document.getElementById('editServerIdx').value = card.dataset.idx;
document.getElementById('editServerName').value = card.dataset.name || '';
document.getElementById('editServerHost').value = card.dataset.host || '';
document.getElementById('editServerPort').value = card.dataset.port || '22';
document.getElementById('editServerUser').value = card.dataset.username || '';
document.getElementById('editServerPassword').value = '';
document.getElementById('editServerKey').value = '';
// Pre-select the tab matching the server's current auth method.
const authIsKey = card.dataset.auth === 'key';
const tabsEl = document.querySelector('#editServerForm .tabs');
const passTab = tabsEl.children[0];
const keyTab = tabsEl.children[1];
switchEditAuthTab(authIsKey ? 'key' : 'password', authIsKey ? keyTab : passTab);
openModal('editServerModal');
}
async function submitEditServer(e) {
e.preventDefault();
const idx = document.getElementById('editServerIdx').value;
const btn = document.getElementById('editServerBtn');
const btnText = document.getElementById('editServerBtnText');
const spinner = document.getElementById('editServerSpinner');
btn.disabled = true;
btnText.textContent = _('saving') || 'Saving...';
spinner.classList.remove('hidden');
try {
const body = {
name: document.getElementById('editServerName').value,
host: document.getElementById('editServerHost').value,
ssh_port: parseInt(document.getElementById('editServerPort').value) || 22,
username: document.getElementById('editServerUser').value,
// Empty -> null means "leave unchanged" on the backend.
password: document.getElementById('editServerPassword').value || null,
private_key: document.getElementById('editServerKey').value || null,
};
await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
showToast(_('server_saved'), 'success');
setTimeout(() => window.location.reload(), 500);
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
btnText.textContent = _('save');
spinner.classList.add('hidden');
}
return false;
}
/* ========== Ping indicators ========== */
async function pingServer(idx) {
const dot = document.getElementById(`ping-${idx}`);
const ms = document.getElementById(`ping-ms-${idx}`);
if (!dot) return;
try {
const res = await fetch(`/api/servers/${idx}/ping`, { credentials: 'same-origin' });
const data = await res.json();
dot.classList.remove('ping-pending');
if (data.alive) {
dot.classList.add('ping-ok');
dot.title = `${data.ms} ms`;
if (ms) ms.textContent = `${data.ms} ms`;
} else {
dot.classList.add('ping-fail');
dot.title = data.error || _('offline');
if (ms) ms.textContent = _('offline');
}
} catch {
dot.classList.remove('ping-pending');
dot.classList.add('ping-fail');
if (ms) ms.textContent = _('offline');
}
}
function startAllPings() {
// Browsers will fan these out concurrently; the page never blocks waiting
// for results because each card updates independently.
document.querySelectorAll('.server-card').forEach(card => {
const idx = card.dataset.idx;
if (idx !== undefined) pingServer(idx);
});
}
/* ========== Drag-and-drop reorder ========== */
let draggedCard = null;
function setupDragAndDrop() {
const grid = document.getElementById('serverGrid');
if (!grid) return;
grid.addEventListener('dragstart', e => {
const card = e.target.closest('.server-card');
if (!card) return;
// Don't initiate drag from a button/link inside the card — those have
// their own click semantics (Edit/Delete/Manage).
if (e.target.closest('button, a')) {
e.preventDefault();
return;
}
draggedCard = card;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
// Some browsers require this for dragstart to fire with text payload.
try { e.dataTransfer.setData('text/plain', card.dataset.idx); } catch {}
});
grid.addEventListener('dragend', e => {
const card = e.target.closest('.server-card');
if (card) card.classList.remove('dragging');
grid.querySelectorAll('.server-card.drag-over').forEach(c => c.classList.remove('drag-over'));
draggedCard = null;
});
grid.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const card = e.target.closest('.server-card');
if (!card || card === draggedCard) return;
grid.querySelectorAll('.server-card.drag-over').forEach(c => {
if (c !== card) c.classList.remove('drag-over');
});
card.classList.add('drag-over');
});
grid.addEventListener('dragleave', e => {
const card = e.target.closest('.server-card');
if (card && !card.contains(e.relatedTarget)) card.classList.remove('drag-over');
});
grid.addEventListener('drop', async e => {
e.preventDefault();
const target = e.target.closest('.server-card');
grid.querySelectorAll('.server-card.drag-over').forEach(c => c.classList.remove('drag-over'));
if (!target || !draggedCard || target === draggedCard) return;
// Insert before/after target depending on cursor position relative
// to the target's vertical centre, which feels natural in a grid.
const rect = target.getBoundingClientRect();
const insertAfter = (e.clientY - rect.top) > rect.height / 2;
grid.insertBefore(draggedCard, insertAfter ? target.nextSibling : target);
const newOrder = Array.from(grid.querySelectorAll('.server-card'))
.map(c => parseInt(c.dataset.idx, 10));
try {
await apiCall('/api/servers/reorder', 'POST', { order: newOrder });
showToast(_('servers_reordered'), 'success');
// Reload so onclick indices and per-card data-idx attrs realign with
// the new server array — simpler and safer than rewriting every handler.
setTimeout(() => window.location.reload(), 400);
} catch (err) {
showToast(err.message, 'error');
// Rollback the visual move — backend rejected.
window.location.reload();
}
});
}
document.addEventListener('DOMContentLoaded', () => {
startAllPings();
setupDragAndDrop();
});
</script>
{% endblock %}