Replace servers nav with 3x-ui clients page showing enabled inbounds

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
test2
2026-07-25 22:39:07 +03:00
co-authored by Cursor
parent 0b3103bd78
commit 170927b8f8
4 changed files with 102 additions and 168 deletions
+38 -84
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.database import get_db from app.database import get_db
from app.models import AdminUser, Protocol, VpnClient, VpnServer, XuiPanel from app.models import AdminUser, Protocol, VpnClient, VpnServer, XuiPanel, XuiPanelInbound, XuiSiteClient
from app.services import vpn as vpn_service from app.services import vpn as vpn_service
from app.services import xui_panels as xui_service from app.services import xui_panels as xui_service
@@ -35,47 +35,41 @@ async def dashboard(request: Request, db: AsyncSession = Depends(get_db), admin=
if _is_redirect(admin): if _is_redirect(admin):
return admin return admin
servers = (await db.execute(select(VpnServer).order_by(VpnServer.id))).scalars().all() xui_panels = await xui_service.list_panels(db)
clients_total = await db.scalar(select(func.count()).select_from(VpnClient)) or 0 xui_clients_total = await db.scalar(select(func.count()).select_from(XuiSiteClient)) or 0
clients_active = await db.scalar( user_inbounds_total = await db.scalar(
select(func.count()).select_from(VpnClient).where(VpnClient.is_enabled.is_(True)) select(func.count())
.select_from(XuiPanelInbound)
.where(XuiPanelInbound.is_available_for_users.is_(True))
) or 0 ) or 0
user_inbounds_by_panel: dict[int, list] = {}
for panel in xui_panels:
rows = await xui_service.list_user_inbounds(db, panel.id)
user_inbounds_by_panel[panel.id] = [
{"inbound_id": r.inbound_id, "protocol": r.protocol, "remark": r.remark, "port": r.port}
for r in rows
]
return request.app.state.templates.TemplateResponse( return request.app.state.templates.TemplateResponse(
"admin/dashboard.html", "admin/dashboard.html",
{ {
"request": request, "request": request,
"admin": admin, "admin": admin,
"servers": servers, "xui_panels": xui_panels,
"clients_total": clients_total, "xui_clients_total": xui_clients_total,
"clients_active": clients_active, "user_inbounds_total": user_inbounds_total,
"user_inbounds_by_panel": user_inbounds_by_panel,
"app_name": request.app.state.settings.app_name, "app_name": request.app.state.settings.app_name,
}, },
) )
@router.get("/servers", response_class=HTMLResponse) @router.get("/servers", response_class=HTMLResponse)
async def servers_page(request: Request, db: AsyncSession = Depends(get_db), admin=Depends(require_admin)): async def servers_page(admin=Depends(require_admin)):
"""WG/AWG servers UI временно скрыт — редирект на клиентов 3x-ui."""
if _is_redirect(admin): if _is_redirect(admin):
return admin return admin
servers = ( return RedirectResponse("/admin/clients", status_code=status.HTTP_303_SEE_OTHER)
await db.execute(
select(VpnServer).options(selectinload(VpnServer.clients)).order_by(VpnServer.id)
)
).scalars().all()
probes = await vpn_service.probe_servers(list(servers))
return request.app.state.templates.TemplateResponse(
"admin/servers.html",
{
"request": request,
"admin": admin,
"servers": servers,
"probes": probes,
"protocols": Protocol,
"app_name": request.app.state.settings.app_name,
"flash": request.query_params.get("flash"),
},
)
@router.post("/servers") @router.post("/servers")
@@ -230,83 +224,43 @@ async def update_server(
@router.get("/clients", response_class=HTMLResponse) @router.get("/clients", response_class=HTMLResponse)
async def clients_page( async def clients_page(
request: Request, request: Request,
protocol: str | None = None,
source: str | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
admin=Depends(require_admin), admin=Depends(require_admin),
): ):
if _is_redirect(admin): if _is_redirect(admin):
return admin return admin
tab = (source or protocol or "wg").lower()
if tab in ("wireguard", "awg2"):
tab = "wg"
if tab not in ("wg", "xui"):
tab = "wg"
query = select(VpnClient).options(selectinload(VpnClient.server)).order_by(VpnClient.id.desc())
if protocol in ("wireguard", "awg2"):
query = query.join(VpnServer).where(VpnServer.protocol == protocol)
tab = "wg"
clients = (await db.execute(query)).scalars().all()
servers = (await db.execute(select(VpnServer).order_by(VpnServer.id))).scalars().all()
xui_panels = await xui_service.list_panels(db) xui_panels = await xui_service.list_panels(db)
xui_clients = await xui_service.list_all_site_clients(db) if tab == "xui" else [] xui_clients = await xui_service.list_all_site_clients(db)
user_inbounds_by_panel: dict[int, list] = {} user_inbounds_by_panel: dict[int, list] = {}
if tab == "xui": for panel in xui_panels:
for panel in xui_panels: rows = await xui_service.list_user_inbounds(db, panel.id)
rows = await xui_service.list_user_inbounds(db, panel.id) user_inbounds_by_panel[panel.id] = [
user_inbounds_by_panel[panel.id] = [ {
{ "inbound_id": r.inbound_id,
"inbound_id": r.inbound_id, "protocol": r.protocol,
"protocol": r.protocol, "remark": r.remark,
"remark": r.remark, "port": r.port,
"port": r.port, }
} for r in rows
for r in rows ]
] panels_with_inbounds = [p for p in xui_panels if user_inbounds_by_panel.get(p.id)]
return request.app.state.templates.TemplateResponse( return request.app.state.templates.TemplateResponse(
"admin/clients.html", "admin/clients.html",
{ {
"request": request, "request": request,
"admin": admin, "admin": admin,
"clients": clients,
"servers": servers,
"xui_panels": xui_panels, "xui_panels": xui_panels,
"xui_clients": xui_clients, "xui_clients": xui_clients,
"user_inbounds_by_panel": user_inbounds_by_panel, "user_inbounds_by_panel": user_inbounds_by_panel,
"tab": tab, "panels_with_inbounds": panels_with_inbounds,
"filter_protocol": protocol,
"protocols": Protocol,
"app_name": request.app.state.settings.app_name, "app_name": request.app.state.settings.app_name,
"flash": request.query_params.get("flash"), "flash": request.query_params.get("flash"),
}, },
) )
@router.post("/clients")
async def create_client(
request: Request,
name: str = Form(...),
server_id: int = Form(...),
notes: str = Form(""),
db: AsyncSession = Depends(get_db),
admin=Depends(require_admin),
):
if _is_redirect(admin):
return admin
try:
await vpn_service.create_client(db, server_id=server_id, name=name, notes=notes or None)
except Exception as exc: # noqa: BLE001
return RedirectResponse(
f"/admin/clients?flash=error:{exc}",
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse("/admin/clients?flash=created", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/clients/xui") @router.post("/clients/xui")
async def create_xui_client_from_clients( async def create_xui_client_from_clients(
request: Request, request: Request,
@@ -328,7 +282,7 @@ async def create_xui_client_from_clients(
panel = await db.get(XuiPanel, panel_id) panel = await db.get(XuiPanel, panel_id)
if not panel: if not panel:
return RedirectResponse( return RedirectResponse(
"/admin/clients?source=xui&flash=error:Панель не найдена", "/admin/clients?flash=error:Панель не найдена",
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
) )
try: try:
@@ -348,11 +302,11 @@ async def create_xui_client_from_clients(
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return RedirectResponse( return RedirectResponse(
f"/admin/clients?source=xui&flash=error:{exc}", f"/admin/clients?flash=error:{exc}",
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
) )
return RedirectResponse( return RedirectResponse(
"/admin/clients?source=xui&flash=created", "/admin/clients?flash=created",
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
) )
+46 -63
View File
@@ -4,12 +4,7 @@
<div class="admin-top"> <div class="admin-top">
<h1>Клиенты</h1> <h1>Клиенты</h1>
<div class="actions"> <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 btn-accent" href="/admin/xui">Управление 3x-ui</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>
</div> </div>
@@ -23,90 +18,77 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if tab == 'wg' %}
<div class="panel" style="margin-bottom:1rem"> <div class="panel" style="margin-bottom:1rem">
<div class="panel-head"><strong>Новый доступ (WireGuard / AWG)</strong></div> <div class="panel-head">
<div class="panel-body"> <strong>Доступные серверы 3x-ui</strong>
<form method="post" action="/admin/clients" class="form-grid"> <span class="muted">добавленные панели и inbound’ы, включённые для пользователей</span>
<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> {% if not xui_panels %}
<div class="panel-body">
<div class="panel"> <p class="muted" style="margin:0">Нет добавленных панелей. Подключите 3x-ui на странице <a href="/admin/xui">3x-ui</a>.</p>
</div>
{% else %}
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Имя</th> <th>Сервер</th>
<th>Протокол</th>
<th>IP</th>
<th>Статус</th> <th>Статус</th>
<th>Создан</th> <th>Inbound’ы для пользователей</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for c in clients %} {% for p in xui_panels %}
{% set inbounds = user_inbounds_by_panel.get(p.id, []) %}
<tr> <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> <td>
{% if c.is_enabled %} <strong>{{ p.name }}</strong>
<span class="badge badge-ok">on</span> <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 %} {% else %}
<span class="badge badge-off">off</span> <span class="badge badge-off">offline</span>
{% endif %} {% endif %}
</td> </td>
<td class="muted">{{ c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '—' }}</td> <td>
<td class="actions"> {% if inbounds %}
<a class="btn btn-sm btn-ghost" href="/admin/clients/{{ c.id }}">Открыть</a> <div style="display:flex;flex-wrap:wrap;gap:.35rem">
<form method="post" action="/admin/clients/{{ c.id }}/toggle" class="inline-form"> {% for ib in inbounds %}
<button class="btn btn-sm btn-ghost" type="submit">{% if c.is_enabled %}Выкл{% else %}Вкл{% endif %}</button> <span class="badge badge-proto" title="port {{ ib.port or '?' }}">
</form> #{{ ib.inbound_id }} {{ ib.remark or ib.protocol }} · {{ ib.protocol }}{% if ib.port %}:{{ ib.port }}{% endif %}
<form method="post" action="/admin/clients/{{ c.id }}/delete" class="inline-form" onsubmit="return confirm('Удалить клиента?')"> </span>
<button class="btn btn-sm btn-danger" type="submit">Удалить</button> {% endfor %}
</form> </div>
{% else %}
<span class="muted">нет включённых — откройте панель и отметьте inbound’ы</span>
{% endif %}
</td>
<td>
<a class="btn btn-sm btn-ghost" href="/admin/xui/{{ p.id }}">Настроить</a>
</td> </td>
</tr> </tr>
{% else %}
<tr><td colspan="6" class="muted">Клиентов пока нет</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
</div> </div>
{% else %}
{# ——— 3x-ui ——— #}
<div class="panel" style="margin-bottom:1rem"> <div class="panel" style="margin-bottom:1rem">
<div class="panel-head"> <div class="panel-head">
<strong>Новый клиент 3x-ui</strong> <strong>Новый клиент</strong>
<span class="muted">только inbound’ы, отмеченные на сервере как доступные пользователям</span> <span class="muted">только по включённым inbound’ам</span>
</div> </div>
<div class="panel-body"> <div class="panel-body">
{% if not xui_panels %} {% if not panels_with_inbounds %}
<p class="muted" style="margin:0">Сначала добавьте панель на странице <a href="/admin/xui">3x-ui</a>.</p> <p class="muted" style="margin:0">Нет серверов с включёнными inbound’ами. Добавьте панель в <a href="/admin/xui">3x-ui</a> и отметьте inbound’ы «для пользователей».</p>
{% else %} {% else %}
<form method="post" action="/admin/clients/xui" class="stack" id="xui-client-form"> <form method="post" action="/admin/clients/xui" class="stack" id="xui-client-form">
<div class="form-grid"> <div class="form-grid">
<label>Сервер 3x-ui <label>Сервер 3x-ui
<select name="panel_id" id="xui-panel" required> <select name="panel_id" id="xui-panel" required>
{% for p in xui_panels %} {% for p in panels_with_inbounds %}
<option value="{{ p.id }}">{{ p.name }}{% if not p.is_reachable %} (offline){% endif %}</option> <option value="{{ p.id }}">{{ p.name }}{% if not p.is_reachable %} (offline){% endif %}</option>
{% endfor %} {% endfor %}
</select> </select>
@@ -147,15 +129,15 @@
<label>Комментарий <label>Комментарий
<input name="comment" /> <input name="comment" />
</label> </label>
<button class="btn btn-accent" type="submit" id="xui-submit" disabled>Создать в 3x-ui</button> <button class="btn btn-accent" type="submit" id="xui-submit" disabled>Создать</button>
<p class="muted" id="xui-inbound-hint" style="margin:0">Нет доступных inbound’ов — откройте сервер в <a href="/admin/xui">3x-ui</a> и отметьте нужные.</p> <p class="muted" id="xui-inbound-hint" style="margin:0">Для выбранного протокола нет включённых inbound’ов.</p>
</form> </form>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-head"><strong>Клиенты, созданные через сайт (3x-ui)</strong></div> <div class="panel-head"><strong>Клиенты через сайт</strong></div>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -191,12 +173,13 @@
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="7" class="muted">Клиентов 3x-ui пока нет</td></tr> <tr><td colspan="7" class="muted">Клиентов пока нет</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% if panels_with_inbounds %}
<script> <script>
(function () { (function () {
const panelSelect = document.getElementById('xui-panel'); const panelSelect = document.getElementById('xui-panel');
+18 -20
View File
@@ -7,51 +7,49 @@
<div class="stats"> <div class="stats">
<div class="stat"> <div class="stat">
<div class="label">Серверы</div> <div class="label">Панели 3x-ui</div>
<div class="value">{{ servers|length }}</div> <div class="value">{{ xui_panels|length }}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="label">Клиенты</div> <div class="label">Клиенты (сайт)</div>
<div class="value">{{ clients_total }}</div> <div class="value">{{ xui_clients_total }}</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="label">Активные</div> <div class="label">Inbound’ы для пользователей</div>
<div class="value">{{ clients_active }}</div> <div class="value">{{ user_inbounds_total }}</div>
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
<div class="panel-head"> <div class="panel-head">
<strong>Протоколы</strong> <strong>Серверы 3x-ui</strong>
<a class="btn btn-sm btn-accent" href="/admin/clients">Управлять клиентами</a> <a class="btn btn-sm btn-accent" href="/admin/clients">Клиенты</a>
</div> </div>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Имя</th> <th>Имя</th>
<th>Протокол</th> <th>URL</th>
<th>Endpoint</th>
<th>Подсеть</th>
<th>Статус</th> <th>Статус</th>
<th>Inbound’ы</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for s in servers %} {% for p in xui_panels %}
<tr> <tr>
<td>{{ s.name }}</td> <td><a href="/admin/xui/{{ p.id }}">{{ p.name }}</a></td>
<td><span class="badge badge-proto">{{ s.protocol }}</span></td> <td class="muted" style="word-break:break-all">{{ p.base_url }}</td>
<td class="mono" style="background:transparent;color:inherit;padding:0">{{ s.public_host }}:{{ s.public_port }}</td>
<td>{{ s.subnet }}</td>
<td> <td>
{% if s.is_enabled %} {% if p.is_reachable %}
<span class="badge badge-ok">enabled</span> <span class="badge badge-ok">online</span>
{% else %} {% else %}
<span class="badge badge-off">disabled</span> <span class="badge badge-off">offline</span>
{% endif %} {% endif %}
</td> </td>
<td class="muted">{{ user_inbounds_by_panel.get(p.id, [])|length }}</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="5" class="muted">Серверы ещё не созданы</td></tr> <tr><td colspan="4" class="muted">Панели ещё не добавлены — <a href="/admin/xui">подключить 3x-ui</a></td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
-1
View File
@@ -9,7 +9,6 @@
</a> </a>
<nav> <nav>
<a href="/admin" class="{% if request.url.path == '/admin' %}active{% endif %}">Дашборд</a> <a href="/admin" class="{% if request.url.path == '/admin' %}active{% endif %}">Дашборд</a>
<a href="/admin/servers" class="{% if '/servers' in request.url.path %}active{% endif %}">Серверы</a>
<a href="/admin/xui" class="{% if '/xui' in request.url.path %}active{% endif %}">3x-ui</a> <a href="/admin/xui" class="{% if '/xui' in request.url.path %}active{% endif %}">3x-ui</a>
<a href="/admin/clients" class="{% if '/clients' in request.url.path %}active{% endif %}">Клиенты</a> <a href="/admin/clients" class="{% if '/clients' in request.url.path %}active{% endif %}">Клиенты</a>
<a href="/" target="_blank">Сайт</a> <a href="/" target="_blank">Сайт</a>