250 lines
9.4 KiB
HTML
250 lines
9.4 KiB
HTML
{% extends "admin/layout.html" %}
|
|
{% block admin_title %}Клиенты{% endblock %}
|
|
{% block admin_content %}
|
|
<div class="admin-top">
|
|
<h1>Клиенты</h1>
|
|
<div class="actions">
|
|
<a class="btn btn-sm {% if tab == 'wg' %}btn-accent{% else %}btn-ghost{% endif %}" href="/admin/clients">WireGuard / AWG</a>
|
|
<a class="btn btn-sm {% if tab == 'xui' %}btn-accent{% else %}btn-ghost{% endif %}" href="/admin/clients?source=xui">3x-ui</a>
|
|
{% if tab == 'wg' %}
|
|
<a class="btn btn-sm btn-ghost" href="/admin/clients?protocol=wireguard">WireGuard</a>
|
|
<a class="btn btn-sm btn-ghost" href="/admin/clients?protocol=awg2">AWG 2.0</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% if flash %}
|
|
{% if flash.startswith('error:') %}
|
|
<div class="flash flash-error">{{ flash[6:] }}</div>
|
|
{% elif flash == 'created' %}
|
|
<div class="flash">Клиент создан</div>
|
|
{% elif flash == 'deleted' %}
|
|
<div class="flash">Клиент удалён</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
{% if tab == 'wg' %}
|
|
<div class="panel" style="margin-bottom:1rem">
|
|
<div class="panel-head"><strong>Новый доступ (WireGuard / AWG)</strong></div>
|
|
<div class="panel-body">
|
|
<form method="post" action="/admin/clients" class="form-grid">
|
|
<label>Имя
|
|
<input name="name" placeholder="phone / laptop" required />
|
|
</label>
|
|
<label>Сервер / протокол
|
|
<select name="server_id" required>
|
|
{% for s in servers %}
|
|
<option value="{{ s.id }}">{{ s.name }} ({{ s.protocol }})</option>
|
|
{% else %}
|
|
<option value="" disabled selected>Нет серверов</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<button class="btn btn-accent" type="submit" {% if not servers %}disabled{% endif %}>Создать</button>
|
|
<label style="grid-column: 1 / -1">Заметка
|
|
<input name="notes" placeholder="опционально" />
|
|
</label>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Имя</th>
|
|
<th>Протокол</th>
|
|
<th>IP</th>
|
|
<th>Статус</th>
|
|
<th>Создан</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for c in clients %}
|
|
<tr>
|
|
<td><a href="/admin/clients/{{ c.id }}">{{ c.name }}</a></td>
|
|
<td><span class="badge badge-proto">{{ c.server.protocol }}</span></td>
|
|
<td>{{ c.address }}</td>
|
|
<td>
|
|
{% if c.is_enabled %}
|
|
<span class="badge badge-ok">on</span>
|
|
{% else %}
|
|
<span class="badge badge-off">off</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="muted">{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '—' }}</td>
|
|
<td class="actions">
|
|
<a class="btn btn-sm btn-ghost" href="/admin/clients/{{ c.id }}">Открыть</a>
|
|
<form method="post" action="/admin/clients/{{ c.id }}/toggle" class="inline-form">
|
|
<button class="btn btn-sm btn-ghost" type="submit">{% if c.is_enabled %}Выкл{% else %}Вкл{% endif %}</button>
|
|
</form>
|
|
<form method="post" action="/admin/clients/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Удалить клиента?')">
|
|
<button class="btn btn-sm btn-danger" type="submit">Удалить</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr><td colspan="6" class="muted">Клиентов пока нет</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{% else %}
|
|
{# ——— 3x-ui ——— #}
|
|
<div class="panel" style="margin-bottom:1rem">
|
|
<div class="panel-head">
|
|
<strong>Новый клиент 3x-ui</strong>
|
|
<span class="muted">только inbound’ы, отмеченные на сервере как доступные пользователям</span>
|
|
</div>
|
|
<div class="panel-body">
|
|
{% if not xui_panels %}
|
|
<p class="muted" style="margin:0">Сначала добавьте панель на странице <a href="/admin/xui">3x-ui</a>.</p>
|
|
{% else %}
|
|
<form method="post" action="/admin/clients/xui" class="stack" id="xui-client-form">
|
|
<div class="form-grid">
|
|
<label>Сервер 3x-ui
|
|
<select name="panel_id" id="xui-panel" required>
|
|
{% for p in xui_panels %}
|
|
<option value="{{ p.id }}">{{ p.name }}{% if not p.is_reachable %} (offline){% endif %}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<label>Протокол
|
|
<select name="protocol" id="xui-protocol" required>
|
|
<option value="vless">VLESS</option>
|
|
<option value="wireguard">WireGuard</option>
|
|
</select>
|
|
</label>
|
|
<label>Inbound
|
|
<select name="inbound_id" id="xui-inbound" required>
|
|
<option value="" disabled selected>Выберите сервер</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div class="form-grid">
|
|
<label>Email / имя
|
|
<input name="email" placeholder="user@example.com" required />
|
|
</label>
|
|
<label>Срок (дни, 0 = безлимит)
|
|
<input type="number" name="expiry_days" value="30" min="0" />
|
|
</label>
|
|
<label>Лимит GB
|
|
<input type="number" name="total_gb" value="0" min="0" />
|
|
</label>
|
|
<label>Limit IP
|
|
<input type="number" name="limit_ip" value="0" min="0" />
|
|
</label>
|
|
</div>
|
|
<label class="check">
|
|
<input type="checkbox" name="start_after_first_use" value="1" checked />
|
|
Старт после первого использования
|
|
</label>
|
|
<label id="xui-flow-wrap">Flow (VLESS)
|
|
<input name="flow" placeholder="xtls-rprx-vision" />
|
|
</label>
|
|
<label>Комментарий
|
|
<input name="comment" />
|
|
</label>
|
|
<button class="btn btn-accent" type="submit" id="xui-submit" disabled>Создать в 3x-ui</button>
|
|
<p class="muted" id="xui-inbound-hint" style="margin:0">Нет доступных inbound’ов — откройте сервер в <a href="/admin/xui">3x-ui</a> и отметьте нужные.</p>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel-head"><strong>Клиенты, созданные через сайт (3x-ui)</strong></div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Email</th>
|
|
<th>Сервер</th>
|
|
<th>Протокол</th>
|
|
<th>Inbound</th>
|
|
<th>Срок</th>
|
|
<th>Создан</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for c in xui_clients %}
|
|
<tr>
|
|
<td>
|
|
<strong>{{ c.email }}</strong>
|
|
{% if c.link %}<div class="muted" style="font-size:.78rem;max-width:240px;word-break:break-all">{{ c.link[:64] }}{% if c.link|length > 64 %}…{% endif %}</div>{% endif %}
|
|
</td>
|
|
<td>{{ c.panel.name if c.panel else '—' }}</td>
|
|
<td><span class="badge badge-proto">{{ c.protocol }}</span></td>
|
|
<td class="muted">#{{ c.inbound_id }} {% if c.inbound_remark %}{{ c.inbound_remark }}{% endif %}</td>
|
|
<td class="muted">
|
|
{% if c.expiry_days and c.expiry_days > 0 %}
|
|
{{ c.expiry_days }} дн.{% if c.start_after_first_use %} · после 1-го входа{% endif %}
|
|
{% else %}без срока{% endif %}
|
|
</td>
|
|
<td class="muted">{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '—' }}</td>
|
|
<td>
|
|
{% if c.panel %}
|
|
<a class="btn btn-sm btn-ghost" href="/admin/xui/{{ c.panel_id }}">Панель</a>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr><td colspan="7" class="muted">Клиентов 3x-ui пока нет</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const panelSelect = document.getElementById('xui-panel');
|
|
const protocolSelect = document.getElementById('xui-protocol');
|
|
const inboundSelect = document.getElementById('xui-inbound');
|
|
const submitBtn = document.getElementById('xui-submit');
|
|
const hint = document.getElementById('xui-inbound-hint');
|
|
const flowWrap = document.getElementById('xui-flow-wrap');
|
|
if (!panelSelect || !inboundSelect) return;
|
|
|
|
const cached = {{ user_inbounds_by_panel | tojson }};
|
|
|
|
function fillInbounds() {
|
|
const panelId = panelSelect.value;
|
|
const protocol = protocolSelect.value;
|
|
const rows = (cached[panelId] || []).filter((r) => r.protocol === protocol);
|
|
inboundSelect.innerHTML = '';
|
|
if (!rows.length) {
|
|
const opt = document.createElement('option');
|
|
opt.value = '';
|
|
opt.disabled = true;
|
|
opt.selected = true;
|
|
opt.textContent = 'Нет доступных inbound’ов';
|
|
inboundSelect.appendChild(opt);
|
|
submitBtn.disabled = true;
|
|
if (hint) hint.style.display = '';
|
|
return;
|
|
}
|
|
rows.forEach((r) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = r.inbound_id;
|
|
opt.textContent = `#${r.inbound_id} ${r.remark || r.protocol} :${r.port || '?'}`;
|
|
inboundSelect.appendChild(opt);
|
|
});
|
|
submitBtn.disabled = false;
|
|
if (hint) hint.style.display = 'none';
|
|
}
|
|
|
|
function toggleFlow() {
|
|
if (flowWrap) flowWrap.style.display = protocolSelect.value === 'vless' ? '' : 'none';
|
|
}
|
|
|
|
panelSelect.addEventListener('change', fillInbounds);
|
|
protocolSelect.addEventListener('change', () => { toggleFlow(); fillInbounds(); });
|
|
toggleFlow();
|
|
fillInbounds();
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|