Add an icons sprite and helpers so nav, actions, and protocol cards use consistent stroke icons. Co-authored-by: Cursor <cursoragent@cursor.com>
454 lines
19 KiB
HTML
454 lines
19 KiB
HTML
{% extends "base.html" %}
|
||
{% from "macros/icons.html" import icon %}
|
||
|
||
{% 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">{{ icon('server') }}</span>
|
||
{{ _('nav_servers') }}
|
||
</h1>
|
||
<button class="btn btn-primary" onclick="openModal('addServerModal')">
|
||
{{ icon('plus') }} {{ _('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">{{ icon('server') }}</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') }}">
|
||
{{ icon('pencil') }}
|
||
</button>
|
||
<button class="btn btn-danger btn-sm btn-icon" onclick="deleteServer({{ loop.index0 }})"
|
||
title="{{ _('delete') }}">
|
||
{{ icon('trash') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div class="empty-state">
|
||
<div class="empty-icon">{{ icon('shield') }}</div>
|
||
<div class="empty-title">{{ _('no_servers') }}</div>
|
||
<div class="empty-desc">
|
||
{{ _('add_server_desc') }}
|
||
</div>
|
||
<button class="btn btn-primary" onclick="openModal('addServerModal')">
|
||
{{ icon('plus') }} {{ _('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 %} |