Files
vpn/app/templates/admin/clients.html
T

233 lines
8.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 btn-accent" href="/admin/xui">Управление 3x-ui</a>
</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 %}
<div class="panel" style="margin-bottom:1rem">
<div class="panel-head">
<strong>Доступные серверы 3x-ui</strong>
<span class="muted">добавленные панели и inbound’ы, включённые для пользователей</span>
</div>
{% if not xui_panels %}
<div class="panel-body">
<p class="muted" style="margin:0">Нет добавленных панелей. Подключите 3x-ui на странице <a href="/admin/xui">3x-ui</a>.</p>
</div>
{% else %}
<table>
<thead>
<tr>
<th>Сервер</th>
<th>Статус</th>
<th>Inbound’ы для пользователей</th>
<th></th>
</tr>
</thead>
<tbody>
{% for p in xui_panels %}
{% set inbounds = user_inbounds_by_panel.get(p.id, []) %}
<tr>
<td>
<strong>{{ p.name }}</strong>
<div class="muted" style="font-size:.8rem;word-break:break-all">{{ p.base_url }}</div>
</td>
<td>
{% if p.is_reachable %}
<span class="badge badge-ok">online</span>
{% else %}
<span class="badge badge-off">offline</span>
{% endif %}
</td>
<td>
{% if inbounds %}
<div style="display:flex;flex-wrap:wrap;gap:.35rem">
{% for ib in inbounds %}
<span class="badge badge-proto" title="port {{ ib.port or '?' }}">
#{{ ib.inbound_id }} {{ ib.remark or ib.protocol }} · {{ ib.protocol }}{% if ib.port %}:{{ ib.port }}{% endif %}
</span>
{% endfor %}
</div>
{% else %}
<span class="muted">нет включённых — откройте панель и отметьте inbound’ы</span>
{% endif %}
</td>
<td>
<a class="btn btn-sm btn-ghost" href="/admin/xui/{{ p.id }}">Настроить</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<div class="panel" style="margin-bottom:1rem">
<div class="panel-head">
<strong>Новый клиент</strong>
<span class="muted">только по включённым inbound’ам</span>
</div>
<div class="panel-body">
{% if not panels_with_inbounds %}
<p class="muted" style="margin:0">Нет серверов с включёнными inbound’ами. Добавьте панель в <a href="/admin/xui">3x-ui</a> и отметьте inbound’ы «для пользователей».</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 panels_with_inbounds %}
<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>Создать</button>
<p class="muted" id="xui-inbound-hint" style="margin:0">Для выбранного протокола нет включённых inbound’ов.</p>
</form>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-head"><strong>Клиенты через сайт</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">Клиентов пока нет</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if panels_with_inbounds %}
<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 %}