mbs-panel/admin.html
savsis 7d140711fd feat: plan prices, payment toggles and HWID limit now editable live from the admin panel, no restart
Closes the last "still .env-only" gap from the backlog (tariffs/HWID) and
fixes a real bug found while building it: payment provider credentials and
enabled-flags were frozen in api.py's process at import time, so a Platega
secret rotation via the settings UI would leave api.py verifying inbound
webhooks against the OLD secret until a manual `mbs restart` — while
bot.py (which does get restarted on save) already had the new one. Same
class of staleness affected HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT and
plan prices, neither of which had any settings UI at all before this.

New `settings.py` module: get_plans()/get_plans_by_code() (live prices,
falls back to config.py defaults), get_payment_settings(), get_hwid_settings(),
yookassa_credentials()/platega_credentials(), set_plan_prices() — all backed
by a new batched legal.read_env_vars() (one file read for N keys instead of
N reads) and legal.update_env_var() (moved out of api.py's private
_update_env_var, which is now a one-line delegate to avoid duplicating the
same env-file-rewrite logic in two places).

api.py and bot.py no longer import PLANS/PLANS_BY_CODE/PAYMENTS_ENABLED/
HWID_LIMIT_ENABLED/HWID_FALLBACK_LIMIT from config as frozen constants —
every read goes through settings.py instead. payments.py no longer imports
YOOKASSA_*/PLATEGA_* from config either; every provider call (create/check
payment, verify webhook signature) reads live credentials at call time.
Every call site inside a loop hoists the live lookup before the loop first
(same N+1 discipline as the rest of tonight), so this doesn't regress
get_subscription's hot path — one settings.get_hwid_settings() call per
request, same as before.

New routes: GET/POST /admin/api/payments/plan-settings (per-plan prices +
a payments_enabled master toggle — there was previously no way to turn
payment collection back off without deleting provider credentials),
GET/POST /admin/api/hwid-settings. Both validate input strictly (prices:
non-negative int; HWID limit: 1-1000) and reject the whole request instead
of partially applying on bad input.

admin.html: new "Тарифы" section in Платежи (price inputs rendered from
the live plan list + payments toggle) and "Лимит устройств (HWID)" in
Настройки, both using the existing .check checkbox / plain-input styling
(no native <select>, per the earlier white-popup complaint). Removed the
now-incorrect "выполни mbs restart" copy from the YooKassa/Platega settings
hints and save-result messages, and added a doc-block for HWID (never had
one) plus extended the Платежи doc-block to mention live-apply. Also
dropped a dead `import links` in bot.py caught by pyflakes while verifying
this.

Verification: api.py/bot.py still can't be imported on this Windows
machine (no prebuilt pydantic-core wheel for Python 3.14, confirmed again
by a fresh pip attempt — same wall as every prior session), so relied on
what's actually exercisable: py_compile + pyflakes (zero undefined names)
across every module including api.py/bot.py, a real runtime test against
an isolated .env fixture covering live price/toggle/HWID reads with zero
reimport, write-idempotency (no duplicate .env lines on repeated saves),
and the concrete bug this fixes end to end — computed an HMAC signature
against an old Platega secret, rotated the secret via update_env_var (the
same call the settings route makes), confirmed the old signature is now
rejected and a new one computed against the rotated secret verifies, all
in the same process with no reimport. Also ran the new CI step's exact
heredoc locally byte-for-byte before adding it to ci.yml. GitHub Actions
still won't trigger for this account (still under abuse-review, ticket
open >2 days) so this is the same substitute-for-CI rigor used all night.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:27:06 +05:00

1683 lines
108 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MBS Panel</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Fira+Mono:wght@500;700&display=swap" rel="stylesheet">
<style>
:root {
/* Tokens lifted from remnawave/frontend's actual Mantine theme.ts (GitHub-dark scale, cyan primary). */
--bg: #0d1117; --sidebar: #010409; --card-tint: rgba(255,255,255,0.02); --card2-tint: rgba(255,255,255,0.045);
--border: rgba(255,255,255,0.08); --border-strong: rgba(255,255,255,0.14);
--text: #c9d1d9; --text-dim: #b1bac4; --muted: #8b949e; --muted2: #6e7681;
--accent: #22b8cf; --accent-deep: #0c8599; --accent-dim: rgba(34,184,207,0.12); --accent-border: rgba(34,184,207,0.35);
--green: #3fb950; --red: #f85149; --yellow: #d29922; --blue: #58a6ff; --pink: #db61a2;
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--radius: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: Montserrat, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased; font-size: 14px;
}
* { scrollbar-color: var(--border-strong) transparent; scrollbar-width: thin; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 10px; border: 2px solid var(--bg); background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: padding-box; }
button, input, select { font-family: inherit; font-size: 14px; }
a { color: var(--accent); text-decoration: none; }
/* ---------- Login ---------- */
#login-screen {
min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
}
.login-card {
max-width: 340px; width: 100%; background: var(--card-tint); border: 1px solid var(--border);
border-radius: 14px; padding: 32px 28px;
opacity: 0; animation: fadeIn 0.25s linear forwards;
}
@keyframes fadeIn { to { opacity: 1; } }
@keyframes enter { to { opacity: 1; transform: translateY(0); filter: blur(0); } }
.splash { display: flex; flex-direction: column; align-items: center; text-align: center; padding-bottom: 22px; }
.splash-mark {
width: 44px; height: 44px; border-radius: 13px; margin-bottom: 14px;
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
display: flex; align-items: center; justify-content: center;
opacity: 0; transform: scale(0.6) rotate(-8deg); filter: blur(4px);
animation: splashMark 0.6s var(--ease) 0.05s forwards;
}
.splash-mark svg { width: 24px; height: 24px; }
.splash-title {
font-size: 19px; font-weight: 700; letter-spacing: -0.01em;
opacity: 0; transform: translateY(8px); filter: blur(3px);
animation: splashRise 0.5s var(--ease) 0.28s forwards;
}
.splash-tagline {
font-size: 11.5px; color: var(--muted2); letter-spacing: 0.04em; margin-top: 4px;
opacity: 0;
animation: splashFade 0.5s var(--ease) 0.5s forwards;
}
@keyframes splashMark { to { opacity: 1; transform: scale(1) rotate(0deg); filter: blur(0); } }
@keyframes splashRise { to { opacity: 1; transform: translateY(0); filter: blur(0); } }
@keyframes splashFade { to { opacity: 1; } }
.login-card h1 { font-size: 17px; margin: 0 0 4px; font-weight: 600; }
.login-card p { color: var(--muted); font-size: 13px; margin: 0 0 20px; }
input[type=password], input[type=text] {
width: 100%; background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: var(--radius);
padding: 11px 13px; color: var(--text); transition: border-color 0.2s var(--ease);
}
input:focus { outline: none; border-color: var(--accent); }
.btn {
padding: 10px 18px; border-radius: var(--radius); border: 1px solid var(--accent-border); cursor: pointer;
background: linear-gradient(135deg, rgba(34,184,207,0.15) 0%, rgba(12,133,153,0.1) 100%); color: var(--accent);
font-weight: 600; transition: all 0.2s ease;
}
.btn:hover { background: rgba(34,184,207,0.22); }
.btn:active { transform: scale(0.97); }
.btn.block { width: 100%; margin-top: 14px; }
.btn.ghost { background: transparent; color: var(--text-dim); border: 1px solid var(--border); }
.btn.ghost:hover { color: var(--text); border-color: var(--border-strong); background: rgba(255,255,255,0.03); }
.btn.danger { background: rgba(248,81,73,0.1); color: var(--red); border: 1px solid rgba(248,81,73,0.3); }
.btn.danger:hover { background: rgba(248,81,73,0.18); }
#login-err { color: var(--red); font-size: 13px; min-height: 16px; margin-top: 10px; }
/* ---------- App shell ---------- */
#app { display: none; min-height: 100vh; grid-template-columns: 216px 1fr; }
#app.show { display: grid; }
.sidebar {
background: var(--sidebar); border-right: 1px solid var(--border); padding: 18px 10px;
display: flex; flex-direction: column;
}
.brand { display: flex; align-items: center; gap: 9px; font-weight: 600; font-size: 14px; padding: 8px 10px 22px; }
.brand .mark {
width: 22px; height: 22px; border-radius: 7px; flex: none;
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
display: flex; align-items: center; justify-content: center;
}
.brand .mark svg { width: 13px; height: 13px; }
.nav-item {
display: flex; align-items: center; gap: 10px; padding: 9px 11px; border-radius: 8px;
color: var(--muted); cursor: pointer; margin-bottom: 1px; font-size: 13.5px;
transition: background 0.2s var(--ease), color 0.2s var(--ease);
}
.nav-item svg { width: 16px; height: 16px; flex: none; opacity: 0.85; }
.nav-item:hover { background: var(--card2-tint); color: var(--text); }
.nav-item.active { background: var(--accent-dim); color: var(--accent); }
.nav-item.active svg { opacity: 1; }
.sidebar-footer { margin-top: auto; padding: 10px 0 0; }
.version-tag { text-align: center; font-size: 11px; color: var(--muted2); margin-top: 10px; font-family: "Fira Mono", ui-monospace, monospace; }
.main { padding: 30px 40px; max-width: 1120px; }
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; letter-spacing: -0.01em; }
.page-sub { color: var(--muted); font-size: 13px; margin: 0 0 26px; }
.view { display: none; }
.view.active { display: block; }
.reveal {
opacity: 0; animation: fadeIn 0.25s linear forwards;
}
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 30px; }
.stat-card {
background: var(--bg); position: relative; padding: 14px 18px 16px;
transition: background 0.2s var(--ease);
}
.stat-card::before { content: ""; position: absolute; inset: 0; background: var(--card-tint); }
.stat-card:hover::before { background: var(--card2-tint); }
.stat-card > * { position: relative; }
.stat-card .l { color: var(--muted); font-size: 11.5px; margin-bottom: 10px; }
.stat-card .row { display: flex; align-items: center; gap: 8px; }
.stat-card svg { width: 16px; height: 16px; flex: none; }
.stat-card .v { font-size: 19px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; font-family: "Fira Mono", ui-monospace, monospace; }
table { width: 100%; border-collapse: collapse; }
.table-wrap {
background: var(--card-tint); border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
}
th {
text-align: left; font-size: 12px; color: var(--muted); font-weight: 500;
padding: 12px 16px; border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.04em;
}
td { padding: 13px 16px; border-bottom: 1px solid var(--border); font-size: 13.5px; }
tr:last-child td { border-bottom: none; }
tbody tr { transition: background 0.15s var(--ease); }
tbody tr:hover { background: var(--card2-tint); }
.badge {
display: inline-block; padding: 2px 9px; border-radius: var(--radius); font-size: 11.5px; font-weight: 600;
border: 1px solid; background: transparent;
}
.badge.ok { border-color: rgba(63,185,80,0.4); color: var(--green); }
.badge.bad { border-color: rgba(248,81,73,0.4); color: var(--red); }
.badge.warn { border-color: rgba(210,153,34,0.4); color: var(--yellow); }
.section { margin-bottom: 32px; }
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.section-head h2 { font-size: 15px; margin: 0; font-weight: 600; }
.check-row { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 14px; }
.check { display: flex; align-items: flex-start; gap: 8px; font-size: 13.5px; cursor: pointer; }
.check input { margin-top: 2px; accent-color: var(--accent); }
.check-hint { color: var(--muted); font-size: 12px; }
.form-row { display: flex; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.form-row > * { flex: 1; min-width: 140px; }
label.f { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; }
/* ---------- Custom dropdown (replaces native <select> — no OS-rendered white
popup, animated open/close) ---------- */
.dd { position: relative; }
.dd-trigger {
width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px;
background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: var(--radius);
padding: 11px 13px; color: var(--text); cursor: pointer; text-align: left;
transition: border-color 0.2s var(--ease);
}
.dd-trigger:hover { border-color: var(--border-strong); }
.dd.dd-open .dd-trigger { border-color: var(--accent); }
.dd-trigger-label.placeholder { color: var(--muted); }
.dd-chevron { width: 15px; height: 15px; color: var(--muted); flex: none; transition: transform 0.2s var(--ease); }
.dd.dd-open .dd-chevron { transform: rotate(180deg); }
.dd-menu {
position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 60;
background: #161b22; border: 1px solid var(--border-strong); border-radius: 10px;
padding: 6px; max-height: 264px; overflow: hidden; display: flex; flex-direction: column;
box-shadow: 0 12px 32px rgba(0,0,0,0.45);
opacity: 0; transform: translateY(-6px) scale(0.98); filter: blur(3px);
pointer-events: none; transition: opacity 0.16s var(--ease), transform 0.16s var(--ease), filter 0.16s var(--ease);
}
.dd-menu.show { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); pointer-events: auto; }
.dd-menu.closing { opacity: 0; transform: translateY(-4px) scale(0.99); filter: blur(2px); }
.dd-search {
background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 7px;
padding: 8px 10px; color: var(--text); margin-bottom: 6px; flex: none; width: 100%;
}
.dd-search:focus { outline: none; border-color: var(--accent); }
.dd-list { overflow-y: auto; }
.dd-option { padding: 9px 10px; border-radius: 7px; cursor: pointer; font-size: 13.5px; transition: background 0.12s var(--ease); }
.dd-option:hover { background: var(--card2-tint); }
.dd-option.selected { color: var(--accent); }
.dd-empty { padding: 10px; color: var(--muted); font-size: 13px; text-align: center; }
.tabs { display: flex; gap: 4px; margin-bottom: 18px; background: var(--card-tint); border: 1px solid var(--border); padding: 4px; border-radius: 10px; width: fit-content; }
.tab { padding: 8px 16px; border-radius: 7px; cursor: pointer; color: var(--muted); font-size: 13px; transition: all 0.2s var(--ease); }
.tab.active { background: var(--card2-tint); color: var(--text); }
.code-box {
background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px;
font-family: "Fira Mono", ui-monospace, monospace; font-size: 12.5px; color: var(--accent);
word-break: break-all; position: relative;
}
.doc-block { margin-bottom: 30px; padding-bottom: 26px; border-bottom: 1px solid var(--border); }
.doc-block:last-child { border-bottom: none; }
.doc-block h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; }
.doc-block p { font-size: 13.5px; color: var(--text-dim); line-height: 1.65; margin: 0 0 10px; }
.doc-block code { background: rgba(255,255,255,0.06); padding: 1px 6px; border-radius: 5px; font-family: "Fira Mono", ui-monospace, monospace; font-size: 12px; }
.copy-btn {
position: absolute; top: 8px; right: 8px; background: var(--card2-tint); border: 1px solid var(--border);
color: var(--muted); border-radius: 6px; padding: 4px 8px; font-size: 11px; cursor: pointer;
}
.copy-btn:hover { color: var(--text); }
.muted-btn { background: none; border: 1px solid transparent; color: var(--muted); cursor: pointer; padding: 4px 8px; border-radius: 6px; font-size: 12.5px; transition: all 0.15s ease; }
.muted-btn:hover { color: var(--accent); border-color: var(--accent-border); background: var(--accent-dim); }
.empty { text-align: center; color: var(--muted); padding: 40px 0; font-size: 13px; }
.drag-handle { cursor: grab; color: var(--muted); text-align: center; user-select: none; font-size: 15px; }
.draggable-row.dragging { opacity: 0.4; }
.draggable-row.drag-over { box-shadow: inset 0 2px 0 var(--accent); }
.draggable-row:active .drag-handle { cursor: grabbing; }
.modal-overlay {
position: fixed; inset: 0; background: rgba(1,4,9,0.6); backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 100;
opacity: 0; pointer-events: none; transition: opacity 0.18s var(--ease);
}
.modal-overlay.show { opacity: 1; pointer-events: auto; }
.modal-card {
width: 100%; max-width: 480px; max-height: 88vh; overflow-y: auto;
background: #161b22; border: 1px solid var(--border-strong); border-radius: 14px; padding: 22px;
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
opacity: 0; transform: translateY(10px) scale(0.98); filter: blur(4px);
transition: opacity 0.2s var(--ease), transform 0.2s var(--ease), filter 0.2s var(--ease);
}
.modal-card-lg { max-width: 620px; }
.uc-sub-row {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px;
font-size: 13.5px;
}
.modal-overlay.show .modal-card { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.modal-head h2 { font-size: 16px; margin: 0; font-weight: 600; }
.modal-close { background: none; border: none; color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
.modal-close:hover { color: var(--text); background: var(--card2-tint); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; }
.device-row {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px;
font-size: 13.5px;
}
.devices-list { max-height: 260px; overflow-y: auto; margin: 4px 0 16px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
.reveal, .login-card, .splash-mark, .splash-title, .splash-tagline, .modal-overlay, .modal-card { opacity: 1 !important; transform: none !important; filter: none !important; }
}
</style>
</head>
<body>
<div id="login-screen">
<div class="login-card">
<div class="splash">
<div class="splash-mark"><svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.2" stroke-linecap="round"><line x1="6" y1="16" x2="6" y2="8"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="18" y1="14" x2="18" y2="10"/></svg></div>
<div class="splash-title">MBS Panel</div>
<div class="splash-tagline">made by savsis</div>
</div>
<div id="login-step-password">
<h1>Вход</h1>
<p>Логин и пароль администратора</p>
<input type="text" id="login-username" placeholder="Логин" value="admin" autocomplete="username" onkeydown="if(event.key==='Enter')document.getElementById('login-password').focus()">
<input type="password" id="login-password" placeholder="Пароль" autocomplete="current-password" onkeydown="if(event.key==='Enter')login()">
<button class="btn block" onclick="login()">Войти</button>
</div>
<div id="login-step-totp" style="display:none">
<h1>Код из приложения</h1>
<p>Двухфакторка включена — введи 6-значный код</p>
<input type="text" id="login-totp-code" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" onkeydown="if(event.key==='Enter')loginTotp()">
<button class="btn block" onclick="loginTotp()">Подтвердить</button>
</div>
<div id="login-err"></div>
</div>
</div>
<div id="app">
<div class="sidebar">
<div class="brand"><div class="mark"><svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.2" stroke-linecap="round"><line x1="6" y1="16" x2="6" y2="8"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="18" y1="14" x2="18" y2="10"/></svg></div>MBS Panel</div>
<div class="nav-item active" data-view="dashboard" onclick="showView('dashboard')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>Дашборд</div>
<div class="nav-item" data-view="subscriptions" onclick="showView('subscriptions')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="17" y2="13"/><line x1="7" y1="17" x2="13" y2="17"/></svg>Подписки</div>
<div class="nav-item" data-view="gifts" onclick="showView('gifts')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="8" width="18" height="13" rx="1.5"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="8" x2="12" y2="21"/><path d="M12 8c-1.2 0-2.3-1.3-2.3-2.6C9.7 4 10.6 3 11.6 3c1.4 0 2.4 2 .4 5"/><path d="M12 8c1.2 0 2.3-1.3 2.3-2.6C14.3 4 13.4 3 12.4 3c-1.4 0-2.4 2-.4 5"/></svg>Гифт-коды</div>
<div class="nav-item" data-view="nodes" onclick="showView('nodes')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/><line x1="7" y1="7" x2="7.01" y2="7"/><line x1="7" y1="17" x2="7.01" y2="17"/></svg>Ноды</div>
<div class="nav-item" data-view="traffic" onclick="showView('traffic')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,13 8,13 10,7 14,19 16,13 21,13"/></svg>Трафик</div>
<div class="nav-item" data-view="payments" onclick="showView('payments')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>Платежи</div>
<div class="nav-item" data-view="docs" onclick="showView('docs')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>Документация</div>
<div class="nav-item" data-view="settings" onclick="showView('settings')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Настройки</div>
<div class="sidebar-footer">
<div class="version-tag" id="logged-in-as" style="margin-bottom:6px"></div>
<button class="btn ghost" style="width:100%" onclick="logout()">Выйти</button>
<div class="version-tag">MBS Panel v1.1.0 · <a href="https://github.com/devsavsis/mbs-panel/releases/latest" target="_blank" style="color:inherit">обновления</a></div>
</div>
</div>
<div class="main">
<div id="view-dashboard" class="view active">
<div class="page-title">Дашборд</div>
<div class="page-sub">Общая статистика по сервису</div>
<div class="stat-grid" id="stat-grid"></div>
<div class="section">
<div class="section-head"><h2>Последние подписки</h2></div>
<div class="table-wrap"><table><thead><tr>
<th>Пользователь</th><th>Сервер</th><th>Тариф</th><th>Истекает</th><th>Статус</th>
</tr></thead><tbody id="recent-subs-body"></tbody></table></div>
</div>
</div>
<div id="view-subscriptions" class="view">
<div class="page-title">Подписки</div>
<div class="page-sub">Все выданные подписки</div>
<div class="table-wrap"><table><thead><tr>
<th>Пользователь</th><th>Сервер</th><th>Тариф</th><th>Выдана</th><th>Истекает</th><th>Статус</th><th></th>
</tr></thead><tbody id="subs-body"></tbody></table></div>
</div>
<div id="view-gifts" class="view">
<div class="page-title">Гифт-коды</div>
<div class="page-sub">Ссылки, которые сразу выдают подписку — работают даже для тех, кто ни разу не открывал бота</div>
<div class="section">
<div class="form-row">
<div><label class="f">Сервер</label><div id="gift-node" class="dd"></div></div>
<div><label class="f">Срок</label><div id="gift-plan" class="dd"></div></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="createGift()">Создать</button></div>
</div>
<div id="gift-result"></div>
</div>
<div class="table-wrap"><table><thead><tr>
<th>Сервер</th><th>Срок</th><th>Создан</th><th>Статус</th><th>Ссылка</th>
</tr></thead><tbody id="gifts-body"></tbody></table></div>
</div>
<div id="view-nodes" class="view">
<div class="page-title">Ноды</div>
<div class="page-sub">Локации, из которых бот выдаёт подписки</div>
<div class="section">
<div class="table-wrap"><table><thead><tr>
<th style="width:28px"></th><th>Локация</th><th>Адрес</th><th>Тип</th><th>Статус</th><th>Live</th><th></th>
</tr></thead><tbody id="nodes-body"></tbody></table></div>
</div>
<div class="section">
<div class="section-head"><h2>Добавить ноду</h2></div>
<div class="tabs">
<div class="tab active" data-tab="guide" onclick="switchNodeTab('guide')">Гайд по установке</div>
<div class="tab" data-tab="manual" onclick="switchNodeTab('manual')">Вручную</div>
</div>
<div id="node-tab-guide">
<p class="page-sub" style="margin-bottom:16px">Заполни данные новой локации — панель сгенерирует ключи и команду, включит TCP+Reality, gRPC+Reality и XHTTP+Reality разом. Выполни команду на чистом Ubuntu-сервере (по SSH) — Xray установится и настроится сам, ничего дополнительно передавать не нужно.</p>
<div class="form-row">
<div><label class="f">Страна</label><div id="ng-country" class="dd"></div></div>
<div><label class="f">Название</label><input type="text" id="ng-label" placeholder="Например: Финляндия (fi2)"></div>
<div><label class="f">Домен/адрес</label><input type="text" id="ng-address" placeholder="fi2.example.com"></div>
</div>
<div class="form-row">
<div><label class="f">Порт (TCP)</label><input type="text" id="ng-port" value="443"></div>
<div><label class="f">SNI-маскировка</label><input type="text" id="ng-sni" value="www.wildberries.ru"></div>
</div>
<div class="check-row">
<label class="check"><input type="checkbox" id="ng-ws"> + WS+TLS с настоящим сертификатом <span class="check-hint">(нужен уже привязанный A-record на этот адрес — certbot выпустит серт прямо в скрипте)</span></label>
<label class="check"><input type="checkbox" id="ng-hy" onchange="document.getElementById('ng-hy-port-wrap').style.display=this.checked?'block':'none'"> + Hysteria2 <span class="check-hint">(отдельный процесс по UDP/QUIC, свой самоподписанный серт — DNS не нужен)</span></label>
</div>
<div class="form-row" id="ng-hy-port-wrap" style="display:none">
<div><label class="f">Порт Hysteria2 (UDP)</label><input type="text" id="ng-hy-port" value="443"></div>
</div>
<button class="btn" onclick="generateGuide()">Сгенерировать команду</button>
<div id="guide-result"></div>
</div>
<div id="node-tab-manual" style="display:none">
<p class="page-sub" style="margin-bottom:16px">Для ноды, которую ты уже настроил(а) сам(а) — просто вставь её параметры Reality.</p>
<div class="form-row">
<div><label class="f">Страна</label><div id="nm-country" class="dd"></div></div>
<div><label class="f">Название</label><input type="text" id="nm-label" placeholder="Название локации"></div>
<div><label class="f">Код</label><input type="text" id="nm-code" placeholder="fi2"></div>
</div>
<div class="form-row">
<div><label class="f">Адрес</label><input type="text" id="nm-address" placeholder="fi2.example.com"></div>
<div><label class="f">Порт</label><input type="text" id="nm-port" value="443"></div>
</div>
<div class="form-row">
<div><label class="f">Public key</label><input type="text" id="nm-pbk"></div>
<div><label class="f">Short ID</label><input type="text" id="nm-sid"></div>
</div>
<div class="form-row">
<div><label class="f">SNI</label><input type="text" id="nm-sni" value="www.wildberries.ru"></div>
<div><label class="f">Shared UUID (если нодой управляешь не ты)</label><input type="text" id="nm-uuid" placeholder="необязательно"></div>
</div>
<button class="btn" onclick="createManualNode()">Добавить ноду</button>
<div id="manual-result"></div>
</div>
</div>
</div>
<div id="edit-node-overlay" class="modal-overlay" onclick="if(event.target===this) closeEditNode()">
<div class="modal-card">
<div class="modal-head">
<h2 id="edit-node-title">Редактировать ноду</h2>
<button class="modal-close" onclick="closeEditNode()">&times;</button>
</div>
<div class="form-row">
<div><label class="f">Страна</label><div id="edit-country" class="dd"></div></div>
<div><label class="f">Название</label><input type="text" id="edit-label"></div>
</div>
<div id="edit-node-advanced">
<div class="form-row">
<div><label class="f">Адрес</label><input type="text" id="edit-address"></div>
<div><label class="f">Порт</label><input type="text" id="edit-port"></div>
</div>
<div class="form-row">
<div><label class="f">SNI</label><input type="text" id="edit-sni"></div>
<div><label class="f">Flow</label><input type="text" id="edit-flow"></div>
</div>
<div class="form-row">
<div><label class="f">Public key</label><input type="text" id="edit-pbk"></div>
<div><label class="f">Short ID</label><input type="text" id="edit-sid"></div>
</div>
<div class="form-row">
<div><label class="f">Shared UUID</label><input type="text" id="edit-uuid" placeholder="необязательно"></div>
</div>
<p class="check-hint" style="margin:2px 0 4px">Смена адреса/ключей/short ID сломает уже выданные ссылки у текущих подписчиков этой ноды — используй только если точно понимаешь, что делаешь.</p>
</div>
<p id="edit-node-de1-note" class="page-sub" style="display:none;margin:0 0 4px">У локальной ноды (de1) параметры подключения берутся из .env на сервере — здесь можно поменять только отображаемое название.</p>
<div class="modal-actions">
<button class="btn ghost" onclick="closeEditNode()">Отмена</button>
<button class="btn" onclick="saveEditNode()">Сохранить</button>
</div>
<div id="edit-node-err"></div>
</div>
</div>
<div id="devices-overlay" class="modal-overlay" onclick="if(event.target===this) closeDevices()">
<div class="modal-card modal-card-lg">
<div class="modal-head">
<h2 id="devices-title">Карточка юзера</h2>
<button class="modal-close" onclick="closeDevices()">&times;</button>
</div>
<div class="tabs">
<div class="tab active" data-uc-tab="subs" onclick="switchUserTab('subs')">Подписки</div>
<div class="tab" data-uc-tab="devices" onclick="switchUserTab('devices')">Устройства</div>
</div>
<div id="uc-tab-subs">
<div id="uc-subs-list" class="devices-list"></div>
<div class="form-row" style="margin-top:6px">
<div><label class="f">Сервер</label><div id="uc-grant-node" class="dd"></div></div>
<div><label class="f">Срок</label><div id="uc-grant-plan" class="dd"></div></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="grantSubscription()">Выдать</button></div>
</div>
</div>
<div id="uc-tab-devices" style="display:none">
<div id="devices-list" class="devices-list"></div>
<label class="f">Лимит устройств для этого юзера</label>
<input type="text" id="devices-limit" placeholder="по умолчанию">
<p class="page-sub" id="devices-limit-hint" style="margin:6px 0 0"></p>
<div class="modal-actions">
<button class="btn" onclick="saveHwidLimit()">Сохранить лимит</button>
</div>
</div>
</div>
</div>
<div id="view-traffic" class="view">
<div class="page-title">Трафик</div>
<div class="page-sub">Суммарно по всем нодам, live через Xray Stats API</div>
<div class="stat-grid" id="traffic-stat-grid" style="grid-template-columns:repeat(3,1fr)"></div>
<div class="section">
<div class="section-head"><h2>По подпискам</h2></div>
<div class="table-wrap"><table><thead><tr>
<th>Пользователь</th><th>Сервер</th><th>Входящий</th><th>Исходящий</th><th>Всего</th><th></th>
</tr></thead><tbody id="traffic-body"></tbody></table></div>
</div>
</div>
<div id="view-payments" class="view">
<div class="page-title">Платежи</div>
<div class="page-sub">ЮKassa / Platega — история и статус, с проверкой на стороне провайдера при пропущенном вебхуке</div>
<div class="section">
<div class="section-head"><h2>Настройка приёма платежей</h2></div>
<p class="page-sub" style="margin-bottom:16px">Заполни один раз — панель сама соберёт из этого публичную оферту и политику конфиденциальности (обязательны для подключения ЮKassa) на своих страницах, готовых к показу клиентам.</p>
<div class="doc-block" style="margin-bottom:20px">
<h2>Как это работает</h2>
<p>1. Заполни реквизиты ниже (кто ты для закона — самозанятый/ИП/ООО, ИНН, контакты). Это те же данные, что ЮKassa попросит при регистрации магазина.</p>
<p>2. Подключи ЮKassa: заведи магазин на <a href="https://yookassa.ru" target="_blank">yookassa.ru</a>, в личном кабинете возьми <b>shop_id</b> и <b>секретный ключ</b> (Настройки → Ключи API), вставь сюда. Панель сразу проверит их и сохранит.</p>
<p>3. Ссылки на готовые оферту и политику (<code>https://{домен}/offer</code>, <code>/privacy</code>) — дай их ЮKassa при регистрации магазина, она их обязательно спросит.</p>
<p class="muted">Самозанятым для приёма платежей от физлиц регистрация магазина в ЮKassa доступна напрямую по паспорту и ИНН, без онлайн-кассы — она уже встроена в сервис ЮKassa. ИП/ООО — обычная регистрация магазина.</p>
</div>
<h3 style="font-size:14px;margin:0 0 12px">Реквизиты для документов</h3>
<div class="form-row">
<div><label class="f">Кто ты</label><div id="legal-type" class="dd"></div></div>
<div><label class="f" id="legal-name-label">ФИО</label><input type="text" id="legal-name" placeholder="Иванов Иван Иванович"></div>
<div><label class="f">ИНН</label><input type="text" id="legal-inn" placeholder="770123456789"></div>
</div>
<div class="form-row" style="margin-top:12px">
<div><label class="f">Email поддержки</label><input type="text" id="legal-email" placeholder="support@example.com"></div>
<div><label class="f">Telegram-контакт поддержки</label><input type="text" id="legal-contact" placeholder="@support"></div>
<div><label class="f">Возврат в течение (часов)</label><input type="text" id="legal-refund" placeholder="24"></div>
</div>
<div class="form-row" style="margin-top:12px">
<button class="btn" onclick="saveLegalSettings()">Сохранить реквизиты</button>
</div>
<div id="legal-result"></div>
<p class="check-hint">Страницы всегда доступны по ссылкам: <a href="/offer" target="_blank" id="legal-offer-link">/offer</a> · <a href="/privacy" target="_blank" id="legal-privacy-link">/privacy</a> — незаполненные поля показываются пометкой, что их надо указать, страница не ломается.</p>
<h3 style="font-size:14px;margin:24px 0 12px">ЮKassa — ключи API</h3>
<div id="yookassa-status" class="page-sub" style="margin-bottom:12px"></div>
<div class="form-row">
<div><label class="f">shop_id</label><input type="text" id="yk-shop-id" placeholder="123456"></div>
<div><label class="f">Секретный ключ</label><input type="password" id="yk-secret-key" placeholder="live_..."></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="saveYookassaSettings()">Проверить и сохранить</button></div>
</div>
<p class="check-hint">Панель сама постучится в ЮKassa (<code>/v3/me</code>) и сохранит ключи только если они рабочие. После сохранения сразу включаются приём оплаты и приём вебхуков — без рестарта; бот на всякий случай перезапускается сам, чтобы кнопки оплаты в Telegram тоже обновились немедленно.</p>
<div id="yookassa-result"></div>
<h3 style="font-size:14px;margin:24px 0 12px">Platega — ключи API</h3>
<div id="platega-status" class="page-sub" style="margin-bottom:12px"></div>
<div class="form-row">
<div><label class="f">Merchant ID</label><input type="text" id="pg-merchant-id" placeholder="merchant_..."></div>
<div><label class="f">Секрет</label><input type="password" id="pg-secret" placeholder="secret_..."></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="savePlategaSettings()">Сохранить</button></div>
</div>
<p class="check-hint">У Platega нет публичного эндпоинта для проверки ключей без реального платежа, так что сохраняется без предварительной проверки — если ключи неверные, это будет видно по первой неудачной оплате. Применяется сразу, без рестарта.</p>
<div id="platega-result"></div>
</div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>Тарифы</h2></div>
<p class="page-sub" style="margin-bottom:16px">Цены по срокам подписки и общий приём оплаты — меняются здесь, применяются сразу, рестарт не нужен.</p>
<label class="check"><input type="checkbox" id="plan-payments-enabled"> Принимать оплату (если выключено — бот всегда выдаёт подписку бесплатно, как без платёжки вообще)</label>
<div class="form-row" style="margin-top:12px" id="plan-price-inputs"></div>
<div class="form-row" style="margin-top:12px">
<button class="btn" onclick="savePlanSettings()">Сохранить тарифы</button>
</div>
<div id="plan-settings-result"></div>
</div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>История</h2></div>
<div class="table-wrap"><table><thead><tr>
<th>Пользователь</th><th>Сервер</th><th>Тариф</th><th>Провайдер</th><th>Сумма</th><th>Создан</th><th>Статус</th><th></th>
</tr></thead><tbody id="payments-body"></tbody></table></div>
</div>
</div>
<div id="view-docs" class="view">
<div class="page-title">Документация</div>
<div class="page-sub">Как устроена панель и как её обслуживать — без похода на GitHub</div>
<div class="doc-block">
<h2>Архитектура</h2>
<p>Панель — три процесса: <b>bot.py</b> (телеграм-бот, aiogram) и <b>api.py</b> (FastAPI — админка + выдача подписок) читают одну SQLite-базу; <b>Xray</b> — отдельный процесс, который реально гоняет трафик. Панель никогда не проксирует VPN-трафик сама, только управляет конфигом Xray и читает его статистику через встроенный Stats API.</p>
<p>На 443 порту одновременно живёт и настоящий HTTPS (для сайта/подписки), и замаскированный под HTTPS VLESS+Reality — их разводит <code>nginx stream</code> модуль по SNI входящего TLS-соединения, до расшифровки.</p>
</div>
<div class="doc-block">
<h2>Ноды</h2>
<p><b>Локальная нода</b> (обычно <code>de1</code>) — Xray на том же сервере, что и панель, управляется напрямую правкой <code>config.json</code>. <b>Управляемые ноды</b> — отдельные серверы, панель ходит на них по SSH management-ключу (генерится сам при первом добавлении ноды, публичная часть раздаётся install-скриптом ноды — панель никогда не просит пароль от нового сервера).</p>
<p>Добавление ноды: Ноды → Добавить ноду → один <code>bash &lt;(curl ...)&gt;</code> на чистый сервер. Редактирование существующей: кнопка «Редактировать» у ноды — для de1 доступно только название (реальные параметры подключения там берутся из <code>.env</code>, не из базы).</p>
<p>Порядок нод в списке (в каком порядке юзеры видят сервера в клиенте) — перетаскиванием за <code>⠿</code> слева от строки, сохраняется сразу без отдельной кнопки. Перед каждым рестартом Xray на ноде панель сама прогоняет <code>xray run -test</code> и проверяет, что TLS-сертификаты реально читаемы тем юзером, под которым крутится Xray — невалидный конфиг или неверные права на серт не применяются, а откатываются с понятной ошибкой вместо падения сервиса.</p>
</div>
<div class="doc-block">
<h2>Пароль и безопасность</h2>
<p>Пароль админ-панели меняется командой <code>mbs pass</code> на сервере (без аргумента — сгенерит случайный). Панель физически откажется стартовать, если в <code>.env</code> стоит "change-me"/"admin"/что-то короче 8 символов — так что пропустить это не выйдет по-тихому.</p>
<p>Сессия логина живёт в httpOnly-куке, опционально поверх пароля — 2FA (TOTP). На <code>/admin/api/login</code> и <code>/admin/api/login/totp</code> висит rate-limit (10 попыток за 15 минут на пароль, 10 за 5 минут на код — с одного IP). SSH-доступ на сервер — сам по себе, панель на него не влияет; отдельно стоит подумать про отключение root-логина по паролю в пользу ключей, если этого ещё не сделано.</p>
<p>Логинов может быть несколько (Настройки → Админы) — у каждого свой пароль и своя 2FA, нельзя удалить последнего оставшегося админа или себя самого, пока залогинен под этим аккаунтом.</p>
</div>
<div class="doc-block">
<h2>Бэкапы</h2>
<p>Настройки → Бэкап и восстановление. Архив — консистентный снапшот базы (через встроенный backup API SQLite, безопасно даже при активной записи) плюс <code>.env</code>. Перед восстановлением панель сама сохраняет копию текущей базы на сервере (<code>mbs.db.before-restore-...</code>) и держит только 5 последних таких копий — старые чистятся сами. Восстановление <code>.env</code> требует ручного <code>mbs restart</code>, чтобы применилось и в API, а не только в боте.</p>
</div>
<div class="doc-block">
<h2>Платежи и вебхуки</h2>
<p>Вкладка Платежи → «Настройка приёма платежей» собирает публичную оферту и политику конфиденциальности (<code>/offer</code>, <code>/privacy</code>) из введённых реквизитов — ЮKassa их спросит при регистрации магазина. Дата вступления в силу проставляется один раз, правки реквизитов её не двигают.</p>
<p>Ключи ЮKassa проверяются вживую через их <code>/v3/me</code> перед сохранением; у Platega такого эндпоинта нет, ключи сохраняются без проверки. Оба провайдера включаются независимо.</p>
<p>Исходящие вебхуки (Настройки → Webhook на события) — панель стучится на указанный URL при оплате (<code>payment.paid</code>) и ручной выдаче подписки админом (<code>subscription.granted_by_admin</code>), тело подписано <code>X-Signature</code> (HMAC-SHA256). Секрет выдаётся один раз и не меняется при правке URL — для интеграций со своими системами, без опроса API.</p>
<p>Вкладка Платежи → «Тарифы» — цены по срокам и общий рубильник приёма оплаты. Как и реквизиты с ключами провайдеров, это читается панелью напрямую из <code>.env</code> при каждом запросе — правки в UI применяются мгновенно везде (бот, API, проверка вебхуков), рестарт панели нигде не требуется.</p>
</div>
<div class="doc-block">
<h2>Лимит устройств (HWID)</h2>
<p>Настройки → «Лимит устройств» — глобальный рубильник и лимит по умолчанию (как у Remnawave: клиент шлёт заголовок <code>x-hwid</code> при запросе конфига, панель запоминает первые N уникальных устройств на юзера и отказывает новым сверх лимита). У конкретного юзера лимит можно переопределить отдельно — в его карточке (Подписки → кнопка «Карточка» → таб «Устройства»), это имеет приоритет над глобальным значением по умолчанию.</p>
</div>
<div class="doc-block">
<h2>fail2ban</h2>
<p><code>install.sh</code> ставит и включает fail2ban автоматически (дефолтный jail — защита SSH от перебора паролей). Проверить, что работает:</p>
<div class="code-box">fail2ban-client status<button class="copy-btn" onclick="copyText('fail2ban-client status')">Копировать</button></div>
<p class="page-sub" style="margin-top:8px">Посмотреть забаненные IP по конкретному джейлу: <code>fail2ban-client status sshd</code>. Разбанить: <code>fail2ban-client set sshd unbanip АЙПИ</code>.</p>
</div>
</div>
<div id="view-settings" class="view">
<div class="page-title">Настройки</div>
<div class="page-sub">Смена телеграм-бота без переустановки панели</div>
<div class="section">
<div class="section-head"><h2>Telegram-бот</h2></div>
<p class="page-sub" style="margin-bottom:16px">Сейчас: <b id="settings-bot-username">—</b> (токен: <span id="settings-bot-token" style="font-family:'Fira Mono',monospace">—</span>)</p>
<div class="form-row">
<div><label class="f">Новый токен (от @BotFather)</label><input type="text" id="settings-bot-token-input" placeholder="123456789:AAAA..."></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="saveBotSettings()">Сменить бота</button></div>
</div>
<p class="check-hint">Панель сама проверит токен у Telegram (запрос getMe) перед применением и подставит настоящий юзернейм бота — придумывать не нужно. После смены перезапустится только бот; если уведомления от api (например об оплате) продолжат идти со старого бота, выполни на сервере <code>mbs restart</code>.</p>
<div id="settings-bot-result"></div>
</div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>Админы</h2></div>
<p class="page-sub" style="margin-bottom:16px">Отдельные логины для входа в панель — на случай если админов несколько.</p>
<div class="table-wrap"><table><thead><tr>
<th>Логин</th><th>Создан</th><th></th>
</tr></thead><tbody id="admins-body"></tbody></table></div>
<div class="form-row" style="margin-top:16px">
<div><label class="f">Логин</label><input type="text" id="new-admin-username" placeholder="Новый логин"></div>
<div><label class="f">Пароль</label><input type="password" id="new-admin-password" placeholder="Минимум 8 символов"></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="createAdmin()">Добавить</button></div>
</div>
<div id="admins-result"></div>
</div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>Двухфакторная аутентификация</h2></div>
<p class="page-sub" style="margin-bottom:16px">Код из Google Authenticator/Authy/1Password при входе, в дополнение к паролю. Настраивается для твоего текущего логина.</p>
<div id="totp-status"></div>
<div id="totp-setup-box" style="display:none;margin-top:16px">
<p class="page-sub">Добавь в приложение-аутентификатор вручную (ключ) или скопируй ссылку:</p>
<div class="code-box"><span id="totp-secret-display"></span><button class="copy-btn" onclick="copyText(document.getElementById('totp-secret-display').textContent)">Копировать</button></div>
<div class="form-row" style="margin-top:12px">
<div><label class="f">Код из приложения</label><input type="text" id="totp-confirm-code" placeholder="000000" maxlength="6" inputmode="numeric"></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="confirmEnableTotp()">Подтвердить</button></div>
</div>
</div>
<div id="totp-disable-box" style="display:none;margin-top:16px">
<div class="form-row">
<div><label class="f">Пароль (подтвердить отключение)</label><input type="password" id="totp-disable-password"></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" style="background:var(--red)" onclick="confirmDisableTotp()">Отключить</button></div>
</div>
</div>
<div id="totp-result"></div>
</div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>Бэкап и восстановление</h2></div>
<p class="page-sub" style="margin-bottom:16px">Бэкап — это база (юзеры, подписки, ноды, платежи) и <code>.env</code> одним файлом. Держи копии где-то отдельно от сервера.</p>
<div class="form-row" style="align-items:flex-start">
<button class="btn" onclick="downloadBackup()">Скачать бэкап</button>
</div>
<div style="margin-top:20px;padding-top:20px;border-top:1px solid var(--border)">
<label class="f">Восстановить из файла</label>
<div class="form-row">
<input type="file" id="restore-file-input" accept=".gz,.tar.gz">
<div style="flex:0"><button class="btn" style="background:var(--red)" onclick="restoreBackup()">Восстановить</button></div>
</div>
<p class="check-hint">⚠ Заменяет текущую базу целиком. Перед заменой панель сама делает копию текущей базы на сервере (файл <code>.before-restore-...</code>), но проверь, что заливаешь именно тот файл, что нужно.</p>
</div>
<div id="backup-result"></div>
</div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>Webhook на события</h2></div>
<p class="page-sub" style="margin-bottom:16px">Панель сама постучится на твой URL при оплате или ручной выдаче подписки — для своих интеграций (CRM, аналитика, что угодно), без опроса API.</p>
<div class="form-row">
<div><label class="f">URL</label><input type="text" id="webhook-url" placeholder="https://example.com/hook"></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="saveWebhookSettings()">Сохранить</button></div>
</div>
<p class="check-hint">
События: <code>payment.paid</code>, <code>subscription.granted_by_admin</code>. Тело — JSON <code>{"event": "...", "data": {...}}</code>, подписано заголовком <code>X-Signature</code> (HMAC-SHA256 от тела запроса на секрете ниже) — так получатель проверяет, что запрос реально от панели.
Секрет для проверки: <code id="webhook-secret-display">—</code>
</p>
<div id="webhook-result"></div>
</div>
<div class="section" style="margin-top:20px">
<div class="section-head"><h2>Лимит устройств (HWID)</h2></div>
<p class="page-sub" style="margin-bottom:16px">Ограничивает число разных устройств на одну подписку — как у Remnawave. У конкретного юзера лимит можно переопределить в его карточке, это значение — только дефолт для тех, у кого свой не задан.</p>
<label class="check"><input type="checkbox" id="hwid-enabled"> Включить лимит устройств</label>
<div class="form-row" style="margin-top:12px">
<div><label class="f">Лимит устройств по умолчанию</label><input type="text" id="hwid-fallback-limit" placeholder="3"></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="saveHwidSettings()">Сохранить</button></div>
</div>
<p class="check-hint">Применяется сразу, без рестарта панели.</p>
<div id="hwid-result"></div>
</div>
</div>
</div>
</div>
<script>
function esc(s) {
if (s === null || s === undefined) return "";
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
async function api(path, opts) {
const res = await fetch(path, { ...opts, headers: { "Content-Type": "application/json", ...(opts && opts.headers) } });
if (res.status === 401) { showLogin(); throw new Error("unauthorized"); }
if (!res.ok) throw new Error(await res.text());
const ct = res.headers.get("content-type") || "";
return ct.includes("application/json") ? res.json() : res.text();
}
function showLogin() {
document.getElementById("login-screen").style.display = "flex";
document.getElementById("app").classList.remove("show");
document.getElementById("login-step-password").style.display = "block";
document.getElementById("login-step-totp").style.display = "none";
document.getElementById("login-totp-code").value = "";
document.getElementById("login-password").value = "";
pendingTotpToken = null;
}
function showApp() {
document.getElementById("login-screen").style.display = "none";
document.getElementById("app").classList.add("show");
loadDashboard();
api("/admin/api/me").then((me) => {
document.getElementById("logged-in-as").textContent = me.username ? "вошёл как " + me.username : "";
}).catch(() => {});
}
let pendingTotpToken = null;
async function login() {
const username = document.getElementById("login-username").value.trim();
const password = document.getElementById("login-password").value;
const err = document.getElementById("login-err");
err.textContent = "";
try {
const res = await api("/admin/api/login", { method: "POST", body: JSON.stringify({ username, password }) });
if (res.needs_totp) {
pendingTotpToken = res.pending_token;
document.getElementById("login-step-password").style.display = "none";
document.getElementById("login-step-totp").style.display = "block";
document.getElementById("login-totp-code").focus();
return;
}
showApp();
} catch (e) {
err.textContent = "Неверный логин или пароль";
}
}
async function loginTotp() {
const code = document.getElementById("login-totp-code").value.trim();
const err = document.getElementById("login-err");
err.textContent = "";
try {
await api("/admin/api/login/totp", { method: "POST", body: JSON.stringify({ pending_token: pendingTotpToken, code }) });
showApp();
} catch (e) {
err.textContent = "Неверный код";
}
}
async function logout() {
await api("/admin/api/logout", { method: "POST" });
showLogin();
}
function showView(name) {
document.querySelectorAll(".view").forEach((v) => v.classList.remove("active"));
document.querySelectorAll(".nav-item").forEach((n) => n.classList.remove("active"));
document.getElementById("view-" + name).classList.add("active");
document.querySelector(`.nav-item[data-view="${name}"]`).classList.add("active");
if (name === "dashboard") loadDashboard();
if (name === "subscriptions") loadSubscriptions();
if (name === "gifts") loadGifts();
if (name === "nodes") loadNodes();
if (name === "traffic") loadTraffic();
if (name === "payments") { loadPayments(); loadPaymentsSettings(); }
if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); loadHwidSettings(); }
}
const COUNTRIES = [
["AU", "Австралия"], ["AT", "Австрия"], ["AZ", "Азербайджан"], ["AL", "Албания"], ["DZ", "Алжир"],
["AO", "Ангола"], ["AD", "Андорра"], ["AG", "Антигуа и Барбуда"], ["AR", "Аргентина"], ["AM", "Армения"],
["AF", "Афганистан"], ["BS", "Багамы"], ["BD", "Бангладеш"], ["BB", "Барбадос"], ["BH", "Бахрейн"],
["BY", "Беларусь"], ["BZ", "Белиз"], ["BE", "Бельгия"], ["BJ", "Бенин"], ["BG", "Болгария"],
["BO", "Боливия"], ["BA", "Босния и Герцеговина"], ["BW", "Ботсвана"], ["BR", "Бразилия"], ["BN", "Бруней"],
["BF", "Буркина-Фасо"], ["BI", "Бурунди"], ["BT", "Бутан"], ["VU", "Вануату"], ["VA", "Ватикан"],
["GB", "Великобритания"], ["HU", "Венгрия"], ["VE", "Венесуэла"], ["TL", "Восточный Тимор"], ["VN", "Вьетнам"],
["GA", "Габон"], ["HT", "Гаити"], ["GY", "Гайана"], ["GM", "Гамбия"], ["GH", "Гана"],
["GT", "Гватемала"], ["GN", "Гвинея"], ["GW", "Гвинея-Бисау"], ["DE", "Германия"], ["HN", "Гондурас"],
["HK", "Гонконг"], ["GD", "Гренада"], ["GR", "Греция"], ["GE", "Грузия"], ["CD", "ДР Конго"],
["DK", "Дания"], ["DJ", "Джибути"], ["DM", "Доминика"], ["DO", "Доминиканская Республика"], ["EG", "Египет"],
["ZM", "Замбия"], ["ZW", "Зимбабве"], ["IL", "Израиль"], ["IN", "Индия"], ["ID", "Индонезия"],
["JO", "Иордания"], ["IQ", "Ирак"], ["IR", "Иран"], ["IE", "Ирландия"], ["IS", "Исландия"],
["ES", "Испания"], ["IT", "Италия"], ["YE", "Йемен"], ["KP", "КНДР"], ["CV", "Кабо-Верде"],
["KZ", "Казахстан"], ["KH", "Камбоджа"], ["CM", "Камерун"], ["CA", "Канада"], ["QA", "Катар"],
["KE", "Кения"], ["CY", "Кипр"], ["KG", "Киргизия"], ["KI", "Кирибати"], ["CN", "Китай"],
["CO", "Колумбия"], ["KM", "Коморы"], ["CG", "Конго"], ["CR", "Коста-Рика"], ["CI", "Кот-д'Ивуар"],
["CU", "Куба"], ["KW", "Кувейт"], ["LA", "Лаос"], ["LV", "Латвия"], ["LS", "Лесото"],
["LR", "Либерия"], ["LB", "Ливан"], ["LY", "Ливия"], ["LT", "Литва"], ["LI", "Лихтенштейн"],
["LU", "Люксембург"], ["MU", "Маврикий"], ["MR", "Мавритания"], ["MG", "Мадагаскар"], ["MO", "Макао"],
["MW", "Малави"], ["MY", "Малайзия"], ["ML", "Мали"], ["MV", "Мальдивы"], ["MT", "Мальта"],
["MA", "Марокко"], ["MH", "Маршалловы Острова"], ["MX", "Мексика"], ["FM", "Микронезия"], ["MZ", "Мозамбик"],
["MD", "Молдова"], ["MC", "Монако"], ["MN", "Монголия"], ["MM", "Мьянма"], ["NA", "Намибия"],
["NR", "Науру"], ["NP", "Непал"], ["NE", "Нигер"], ["NG", "Нигерия"], ["NL", "Нидерланды"],
["NI", "Никарагуа"], ["NZ", "Новая Зеландия"], ["NO", "Норвегия"], ["AE", "ОАЭ"], ["OM", "Оман"],
["PK", "Пакистан"], ["PW", "Палау"], ["PA", "Панама"], ["PG", "Папуа — Новая Гвинея"], ["PY", "Парагвай"],
["PE", "Перу"], ["PL", "Польша"], ["PT", "Португалия"], ["RU", "Россия"], ["RW", "Руанда"],
["RO", "Румыния"], ["US", "США"], ["SV", "Сальвадор"], ["WS", "Самоа"], ["SM", "Сан-Марино"],
["ST", "Сан-Томе и Принсипи"], ["SA", "Саудовская Аравия"], ["MK", "Северная Македония"], ["SC", "Сейшелы"], ["SN", "Сенегал"],
["VC", "Сент-Винсент и Гренадины"], ["KN", "Сент-Китс и Невис"], ["LC", "Сент-Люсия"], ["RS", "Сербия"], ["SG", "Сингапур"],
["SY", "Сирия"], ["SK", "Словакия"], ["SI", "Словения"], ["SB", "Соломоновы Острова"], ["SO", "Сомали"],
["SD", "Судан"], ["SR", "Суринам"], ["SL", "Сьерра-Леоне"], ["TJ", "Таджикистан"], ["TH", "Таиланд"],
["TW", "Тайвань"], ["TZ", "Танзания"], ["TG", "Того"], ["TO", "Тонга"], ["TT", "Тринидад и Тобаго"],
["TV", "Тувалу"], ["TN", "Тунис"], ["TM", "Туркменистан"], ["TR", "Турция"], ["UG", "Уганда"],
["UZ", "Узбекистан"], ["UA", "Украина"], ["UY", "Уругвай"], ["FJ", "Фиджи"], ["PH", "Филиппины"],
["FI", "Финляндия"], ["FR", "Франция"], ["HR", "Хорватия"], ["CF", "ЦАР"], ["TD", "Чад"],
["ME", "Черногория"], ["CZ", "Чехия"], ["CL", "Чили"], ["CH", "Швейцария"], ["SE", "Швеция"],
["LK", "Шри-Ланка"], ["GQ", "Экв. Гвинея"], ["EC", "Эквадор"], ["ER", "Эритрея"], ["SZ", "Эсватини"],
["EE", "Эстония"], ["ET", "Эфиопия"], ["ZA", "ЮАР"], ["KR", "Южная Корея"], ["SS", "Южный Судан"],
["JM", "Ямайка"], ["JP", "Япония"],
];
function flagEmoji(code) {
return code.split("").map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join("");
}
function flagToCode(label) {
const chars = Array.from(label || "");
if (chars.length < 2) return null;
const cp1 = chars[0].codePointAt(0) - 127397;
const cp2 = chars[1].codePointAt(0) - 127397;
if (cp1 < 65 || cp1 > 90 || cp2 < 65 || cp2 > 90) return null;
return String.fromCharCode(cp1) + String.fromCharCode(cp2);
}
function createDropdown(id, { options, value, placeholder, searchable, onChange }) {
const container = document.getElementById(id);
container.classList.add("dd");
container.innerHTML = `
<button type="button" class="dd-trigger">
<span class="dd-trigger-label placeholder">${esc(placeholder || "Выбери")}</span>
<svg class="dd-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6,9 12,15 18,9"/></svg>
</button>
<div class="dd-menu">
${searchable ? '<input type="text" class="dd-search" placeholder="Поиск…">' : ""}
<div class="dd-list"></div>
</div>`;
const trigger = container.querySelector(".dd-trigger");
const label = container.querySelector(".dd-trigger-label");
const menu = container.querySelector(".dd-menu");
const list = container.querySelector(".dd-list");
const search = container.querySelector(".dd-search");
let current = options || [];
let val = value ?? null;
let open = false;
let closeTimer = null;
function renderList(filter) {
const f = (filter || "").trim().toLowerCase();
const filtered = f ? current.filter((o) => o.label.toLowerCase().includes(f)) : current;
list.innerHTML = filtered.length
? filtered.map((o) => `<div class="dd-option${o.value === val ? " selected" : ""}" data-value="${esc(o.value)}">${o.html || esc(o.label)}</div>`).join("")
: '<div class="dd-empty">Ничего не найдено</div>';
}
function updateLabel() {
const found = current.find((o) => o.value === val);
label.textContent = found ? found.label : (placeholder || "Выбери");
label.classList.toggle("placeholder", !found);
}
function onDocClick(e) {
if (!container.contains(e.target)) closeMenu();
}
function openMenu() {
if (open) return;
open = true;
if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
container.classList.add("dd-open");
renderList("");
menu.classList.remove("closing");
requestAnimationFrame(() => menu.classList.add("show"));
if (search) { search.value = ""; setTimeout(() => search.focus(), 30); }
document.addEventListener("click", onDocClick, true);
document.addEventListener("keydown", onKeydown);
}
function closeMenu() {
if (!open) return;
open = false;
container.classList.remove("dd-open");
menu.classList.remove("show");
menu.classList.add("closing");
document.removeEventListener("click", onDocClick, true);
document.removeEventListener("keydown", onKeydown);
closeTimer = setTimeout(() => menu.classList.remove("closing"), 200);
}
function onKeydown(e) {
if (e.key === "Escape") closeMenu();
}
trigger.addEventListener("click", () => (open ? closeMenu() : openMenu()));
if (search) search.addEventListener("input", () => renderList(search.value));
list.addEventListener("click", (e) => {
const opt = e.target.closest(".dd-option");
if (!opt) return;
val = opt.dataset.value;
updateLabel();
closeMenu();
if (onChange) onChange(val);
});
updateLabel();
return {
setOptions(opts) { current = opts; updateLabel(); },
setValue(v) { val = v; updateLabel(); },
getValue() { return val; },
};
}
const DD = {};
function initDropdowns() {
const countryOptions = COUNTRIES.map(([code, name]) => ({
value: code, label: `${flagEmoji(code)} ${name}`,
}));
DD.ngCountry = createDropdown("ng-country", {
options: countryOptions, placeholder: "Страна", searchable: true,
onChange: (code) => {
const c = COUNTRIES.find((x) => x[0] === code);
if (c) document.getElementById("ng-label").value = `${flagEmoji(code)} ${c[1]}`;
},
});
DD.nmCountry = createDropdown("nm-country", {
options: countryOptions, placeholder: "Страна", searchable: true,
onChange: (code) => {
const c = COUNTRIES.find((x) => x[0] === code);
if (c) document.getElementById("nm-label").value = `${flagEmoji(code)} ${c[1]}`;
},
});
DD.giftNode = createDropdown("gift-node", { options: [], placeholder: "Сервер" });
DD.giftPlan = createDropdown("gift-plan", { options: [], placeholder: "Срок" });
DD.editCountry = createDropdown("edit-country", {
options: countryOptions, placeholder: "Страна", searchable: true,
onChange: (code) => {
const c = COUNTRIES.find((x) => x[0] === code);
if (c) document.getElementById("edit-label").value = `${flagEmoji(code)} ${c[1]}`;
},
});
DD.ucGrantNode = createDropdown("uc-grant-node", { options: [], placeholder: "Сервер" });
DD.ucGrantPlan = createDropdown("uc-grant-plan", { options: [], placeholder: "Срок" });
DD.legalType = createDropdown("legal-type", {
options: [
{ value: "self", label: "Самозанятый" },
{ value: "ip", label: "ИП" },
{ value: "ooo", label: "ООО" },
],
value: "self", placeholder: "Кто ты",
onChange: (type) => {
const label = document.getElementById("legal-name-label");
const input = document.getElementById("legal-name");
if (type === "ooo") { label.textContent = "Название"; input.placeholder = 'ООО «Ромашка»'; }
else { label.textContent = "ФИО"; input.placeholder = "Иванов Иван Иванович"; }
},
});
}
const ICONS = {
users: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6"/><circle cx="17.5" cy="9" r="2.4"/><path d="M21 20c0-2.6-1.8-4.8-4.2-5.5"/></svg>',
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.5 2.5L16 9.5"/></svg>',
box: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8l-9-5-9 5 9 5 9-5z"/><path d="M3 8v8l9 5 9-5V8"/><line x1="12" y1="13" x2="12" y2="21"/></svg>',
gift: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="8" width="18" height="13" rx="1.5"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="8" x2="12" y2="21"/></svg>',
up: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="6,11 12,5 18,11"/></svg>',
down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="6,13 12,19 18,13"/></svg>',
pulse: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,13 8,13 10,7 14,19 16,13 21,13"/></svg>',
};
function statCard(label, value, color, icon, delay) {
return `<div class="stat-card reveal" style="animation-delay:${delay}s">
<div class="l">${label}</div>
<div class="row" style="color:${color}">${icon}<span class="v" style="color:var(--text)">${value}</span></div>
</div>`;
}
function rowAttr(i) {
return `class="reveal" style="animation-delay:${Math.min(i, 10) * 0.025}s"`;
}
async function loadTraffic() {
const t = await api("/admin/api/traffic");
const grid = document.getElementById("traffic-stat-grid");
grid.innerHTML = [
["Входящий (всего)", t.total_up_fmt, "var(--accent)", ICONS.up],
["Исходящий (всего)", t.total_down_fmt, "var(--blue)", ICONS.down],
["Суммарно", t.total_fmt, "var(--accent)", ICONS.pulse],
].map(([l, v, c, ic], i) => statCard(l, v, c, ic, i * 0.05)).join("");
const body = document.getElementById("traffic-body");
body.innerHTML = t.per_subscription.length ? t.per_subscription.map((r, i) => `
<tr ${rowAttr(i)}><td>${esc(r.username)}</td><td>${esc(r.node_label)}</td><td>${r.up_fmt}</td><td>${r.down_fmt}</td><td>${r.total_fmt}</td>
<td><button class="muted-btn" onclick="resetTraffic('${r.uuid}', this)">Сбросить</button></td></tr>
`).join("") : '<tr><td colspan="6"><div class="empty">Пока нет данных по трафику</div></td></tr>';
}
async function resetTraffic(uuid, btn) {
if (!confirm("Сбросить счётчик трафика для этой подписки?")) return;
btn.disabled = true;
btn.textContent = "…";
try {
await api(`/admin/api/subscriptions/${uuid}/reset-traffic`, { method: "POST" });
loadTraffic();
} catch (e) {
btn.disabled = false;
btn.textContent = "Сбросить";
}
}
function paymentStatusBadge(status) {
if (status === "paid") return '<span class="badge ok">оплачен</span>';
if (status === "failed") return '<span class="badge bad">не прошёл</span>';
return '<span class="badge warn">ожидание</span>';
}
async function loadPayments() {
const rows = await api("/admin/api/payments");
const body = document.getElementById("payments-body");
body.innerHTML = rows.length ? rows.map((p, i) => `
<tr ${rowAttr(i)}>
<td>tg${p.tg_id}</td><td>${esc(p.node_label)}</td><td>${esc(p.plan_label)}</td>
<td>${esc(p.provider_label)}</td><td>${p.amount} ₽</td><td>${fmtDate(p.created_at)}</td>
<td>${paymentStatusBadge(p.status)}</td>
<td>${p.status === "pending" ? `<button class="muted-btn" onclick="checkPayment('${p.id}', this)">Проверить</button>` : ""}</td>
</tr>
`).join("") : '<tr><td colspan="8"><div class="empty">Пока нет платежей</div></td></tr>';
}
async function checkPayment(id, btn) {
btn.disabled = true;
btn.textContent = "…";
try {
await api(`/admin/api/payments/${id}/check`, { method: "POST" });
} finally {
loadPayments();
}
}
async function loadPaymentsSettings() {
const [legalRes, ykRes, pgRes, planRes] = await Promise.all([
api("/admin/api/payments/legal-settings"),
api("/admin/api/payments/yookassa-settings"),
api("/admin/api/payments/platega-settings"),
api("/admin/api/payments/plan-settings"),
]);
document.getElementById("legal-name").value = legalRes.LEGAL_NAME || "";
document.getElementById("legal-inn").value = legalRes.LEGAL_INN || "";
document.getElementById("legal-refund").value = legalRes.REFUND_HOURS || "24";
document.getElementById("legal-contact").value = legalRes.SUPPORT_CONTACT || "";
document.getElementById("legal-email").value = legalRes.SUPPORT_EMAIL || "";
document.getElementById("yk-shop-id").value = ykRes.shop_id || "";
const ykStatus = document.getElementById("yookassa-status");
if (ykRes.enabled && ykRes.has_secret) {
ykStatus.innerHTML = '<span class="badge ok">подключена</span> shop_id: ' + esc(ykRes.shop_id);
} else {
ykStatus.innerHTML = '<span class="badge bad">не настроена</span>';
}
document.getElementById("pg-merchant-id").value = pgRes.merchant_id || "";
const pgStatus = document.getElementById("platega-status");
if (pgRes.enabled && pgRes.has_secret) {
pgStatus.innerHTML = '<span class="badge ok">подключена</span> merchant_id: ' + esc(pgRes.merchant_id);
} else {
pgStatus.innerHTML = '<span class="badge bad">не настроена</span>';
}
document.getElementById("plan-payments-enabled").checked = !!planRes.payments_enabled;
document.getElementById("plan-price-inputs").innerHTML = planRes.plans.map((p) => `
<div><label class="f">${esc(p.label)}</label><input type="text" data-plan-code="${esc(p.code)}" class="plan-price-input" value="${p.price}"></div>
`).join("");
}
async function saveLegalSettings() {
const result = document.getElementById("legal-result");
const body = {
LEGAL_NAME: document.getElementById("legal-name").value.trim(),
LEGAL_INN: document.getElementById("legal-inn").value.trim(),
REFUND_HOURS: document.getElementById("legal-refund").value.trim() || "24",
SUPPORT_CONTACT: document.getElementById("legal-contact").value.trim(),
SUPPORT_EMAIL: document.getElementById("legal-email").value.trim(),
};
try {
await api("/admin/api/payments/legal-settings", { method: "POST", body: JSON.stringify(body) });
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено — страницы /offer и /privacy обновились сразу, без рестарта</p>';
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
async function saveYookassaSettings() {
const shop_id = document.getElementById("yk-shop-id").value.trim();
const secret_key = document.getElementById("yk-secret-key").value.trim();
const result = document.getElementById("yookassa-result");
if (!shop_id || !secret_key) return;
result.innerHTML = '<p class="page-sub" style="margin-top:10px">Проверяю ключи у ЮKassa…</p>';
try {
await api("/admin/api/payments/yookassa-settings", { method: "POST", body: JSON.stringify({ shop_id, secret_key }) });
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Ключи рабочие, сохранено — бот и приём вебхуков подхватывают их сразу, рестарт не нужен.</p>';
document.getElementById("yk-secret-key").value = "";
loadPaymentsSettings();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
async function savePlategaSettings() {
const merchant_id = document.getElementById("pg-merchant-id").value.trim();
const secret = document.getElementById("pg-secret").value.trim();
const result = document.getElementById("platega-result");
if (!merchant_id || !secret) return;
try {
await api("/admin/api/payments/platega-settings", { method: "POST", body: JSON.stringify({ merchant_id, secret }) });
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено — бот и приём вебхуков подхватывают ключи сразу, рестарт не нужен.</p>';
document.getElementById("pg-secret").value = "";
loadPaymentsSettings();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
async function savePlanSettings() {
const result = document.getElementById("plan-settings-result");
const prices = {};
document.querySelectorAll(".plan-price-input").forEach((el) => {
prices[el.dataset.planCode] = el.value.trim();
});
const body = {
payments_enabled: document.getElementById("plan-payments-enabled").checked,
prices,
};
try {
await api("/admin/api/payments/plan-settings", { method: "POST", body: JSON.stringify(body) });
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено — применилось сразу, без рестарта</p>';
loadPaymentsSettings();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
async function loadHwidSettings() {
const res = await api("/admin/api/hwid-settings");
document.getElementById("hwid-enabled").checked = !!res.enabled;
document.getElementById("hwid-fallback-limit").value = res.fallback_limit;
document.getElementById("hwid-result").innerHTML = "";
}
async function saveHwidSettings() {
const result = document.getElementById("hwid-result");
const body = {
enabled: document.getElementById("hwid-enabled").checked,
fallback_limit: document.getElementById("hwid-fallback-limit").value.trim(),
};
try {
await api("/admin/api/hwid-settings", { method: "POST", body: JSON.stringify(body) });
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено — применилось сразу, без рестарта</p>';
loadHwidSettings();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
async function loadBotSettings() {
const data = await api("/admin/api/settings/bot");
document.getElementById("settings-bot-username").textContent = "@" + data.username;
document.getElementById("settings-bot-token").textContent = data.token_masked;
document.getElementById("settings-bot-result").innerHTML = "";
}
async function saveBotSettings() {
const input = document.getElementById("settings-bot-token-input");
const token = input.value.trim();
const result = document.getElementById("settings-bot-result");
if (!token) return;
result.innerHTML = '<p class="page-sub" style="margin-top:10px">Проверяю токен у Telegram…</p>';
try {
const res = await api("/admin/api/settings/bot", { method: "POST", body: JSON.stringify({ token }) });
result.innerHTML = `<p class="page-sub" style="margin-top:10px;color:var(--green)">Готово: бот сменён на @${esc(res.username)}${res.restarted ? "" : " (сохранено, но авто-рестарт не удался — перезапусти вручную: mbs restart)"}</p>`;
input.value = "";
loadBotSettings();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось — проверь токен и попробуй снова</p>';
}
}
let currentAdminUsername = null;
async function loadAdmins() {
const admins = await api("/admin/api/admins");
const me = await api("/admin/api/me");
currentAdminUsername = me.username;
const body = document.getElementById("admins-body");
body.innerHTML = admins.map((a, i) => `
<tr ${rowAttr(i)}>
<td>${esc(a.username)}${a.username === currentAdminUsername ? ' <span class="badge ok">это ты</span>' : ""}</td>
<td>${fmtDate(a.created_at)}</td>
<td>${admins.length > 1 && a.username !== currentAdminUsername ? `<button class="muted-btn" onclick="deleteAdmin(${a.id})">Удалить</button>` : ""}</td>
</tr>
`).join("");
}
async function createAdmin() {
const username = document.getElementById("new-admin-username").value.trim();
const password = document.getElementById("new-admin-password").value;
const result = document.getElementById("admins-result");
if (!username || !password) return;
try {
await api("/admin/api/admins", { method: "POST", body: JSON.stringify({ username, password }) });
document.getElementById("new-admin-username").value = "";
document.getElementById("new-admin-password").value = "";
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Админ добавлен</p>';
loadAdmins();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
async function deleteAdmin(id) {
if (!confirm("Удалить этого админа?")) return;
try {
await api(`/admin/api/admins/${id}`, { method: "DELETE" });
loadAdmins();
} catch (e) {
document.getElementById("admins-result").innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
async function loadTotpStatus() {
const status = document.getElementById("totp-status");
const setupBox = document.getElementById("totp-setup-box");
const disableBox = document.getElementById("totp-disable-box");
setupBox.style.display = "none";
disableBox.style.display = "none";
const s = await api("/admin/api/2fa/status");
if (s.enabled) {
status.innerHTML = '<p class="page-sub"><span class="badge ok">включена</span></p>';
status.innerHTML += '<button class="muted-btn" onclick="document.getElementById(\'totp-disable-box\').style.display=\'block\'">Отключить</button>';
} else {
status.innerHTML = '<p class="page-sub"><span class="badge bad">выключена</span></p>';
status.innerHTML += '<button class="btn" onclick="startEnableTotp()">Включить 2FA</button>';
}
}
async function startEnableTotp() {
const res = await api("/admin/api/2fa/setup", { method: "POST" });
document.getElementById("totp-secret-display").textContent = res.secret;
document.getElementById("totp-setup-box").dataset.secret = res.secret;
document.getElementById("totp-setup-box").style.display = "block";
}
async function confirmEnableTotp() {
const secret = document.getElementById("totp-setup-box").dataset.secret;
const code = document.getElementById("totp-confirm-code").value.trim();
const result = document.getElementById("totp-result");
try {
await api("/admin/api/2fa/enable", { method: "POST", body: JSON.stringify({ secret, code }) });
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">2FA включена</p>';
loadTotpStatus();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Неверный код</p>';
}
}
async function confirmDisableTotp() {
const password = document.getElementById("totp-disable-password").value;
const result = document.getElementById("totp-result");
try {
await api("/admin/api/2fa/disable", { method: "POST", body: JSON.stringify({ password }) });
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">2FA отключена</p>';
document.getElementById("totp-disable-password").value = "";
loadTotpStatus();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Неверный пароль</p>';
}
}
async function loadWebhookSettings() {
const res = await api("/admin/api/webhook-settings");
document.getElementById("webhook-url").value = res.url || "";
document.getElementById("webhook-secret-display").textContent = res.secret || "будет создан при сохранении URL";
}
async function saveWebhookSettings() {
const url = document.getElementById("webhook-url").value.trim();
const result = document.getElementById("webhook-result");
try {
await api("/admin/api/webhook-settings", { method: "POST", body: JSON.stringify({ url }) });
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено</p>';
loadWebhookSettings();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
function downloadBackup() {
window.location.href = "/admin/api/backup";
}
async function restoreBackup() {
const input = document.getElementById("restore-file-input");
const result = document.getElementById("backup-result");
const file = input.files[0];
if (!file) return;
if (!confirm("Заменить текущую базу файлом " + file.name + "? Текущая база сохранится в файл .before-restore-... на сервере, но действие лучше не отменять просто так.")) return;
result.innerHTML = '<p class="page-sub" style="margin-top:10px">Восстанавливаю…</p>';
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/admin/api/backup/restore", { method: "POST", body: form });
if (res.status === 401) { showLogin(); return; }
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
result.innerHTML = `<p class="page-sub" style="margin-top:10px;color:var(--green)">Готово. Копия старой базы: <code>${esc(data.safety_copy)}</code>.${data.restored_env ? (data.restarted_bot ? " Бот перезапущен с новым .env." : " .env восстановлен, но бот сам не перезапустился — выполни mbs restart.") : ""}</p>`;
input.value = "";
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
function fmtDate(iso) { return iso ? iso.slice(0, 10) : "—"; }
function statusBadge(active, daysLeft) {
if (!active) return '<span class="badge bad">истекла</span>';
if (daysLeft <= 2) return '<span class="badge warn">' + daysLeft + ' дн.</span>';
return '<span class="badge ok">' + daysLeft + ' дн.</span>';
}
async function loadDashboard() {
const stats = await api("/admin/api/stats");
const grid = document.getElementById("stat-grid");
grid.innerHTML = [
["Пользователей", stats.users, "var(--blue)", ICONS.users],
["Активных подписок", stats.active_subscriptions, "var(--green)", ICONS.check],
["Всего подписок", stats.total_subscriptions, "var(--accent)", ICONS.box],
["Гифт-коды (созд./исп.)", stats.gifts_created + " / " + stats.gifts_used, "var(--pink)", ICONS.gift],
].map(([l, v, c, ic], i) => statCard(l, v, c, ic, i * 0.05)).join("");
const recent = await api("/admin/api/subscriptions?limit=8");
const body = document.getElementById("recent-subs-body");
body.innerHTML = recent.length ? recent.map((s, i) => `
<tr ${rowAttr(i)}><td>${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}</td><td>${esc(s.node_label)}</td><td>${esc(s.plan_label)}</td>
<td>${fmtDate(s.expires_at)}</td><td>${statusBadge(s.active, s.days_left)}</td></tr>
`).join("") : '<tr><td colspan="5"><div class="empty">Пока нет подписок</div></td></tr>';
}
async function loadSubscriptions() {
const subs = await api("/admin/api/subscriptions");
const body = document.getElementById("subs-body");
body.innerHTML = subs.length ? subs.map((s, i) => `
<tr ${rowAttr(i)}><td>${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}</td><td>${esc(s.node_label)}</td><td>${esc(s.plan_label)}</td>
<td>${fmtDate(s.created_at)}</td><td>${fmtDate(s.expires_at)}</td><td>${statusBadge(s.active, s.days_left)}</td>
<td>
<button class="muted-btn" onclick="openUserCard(${s.tg_id}, '${esc(s.username ? '@' + s.username : 'tg' + s.tg_id)}')">Карточка</button>
${s.active ? `<button class="muted-btn" onclick="revokeSub('${s.uuid}')">Отозвать</button>` : ""}
</td></tr>
`).join("") : '<tr><td colspan="7"><div class="empty">Пока нет подписок</div></td></tr>';
}
let devicesTgId = null;
async function openUserCard(tgId, label) {
devicesTgId = tgId;
document.getElementById("devices-title").textContent = `Карточка: ${label}`;
document.getElementById("uc-subs-list").innerHTML = '<div class="empty">Загрузка…</div>';
document.getElementById("devices-list").innerHTML = "";
switchUserTab("subs");
document.getElementById("devices-overlay").classList.add("show");
if (!plansCache) plansCache = await api("/admin/api/plans");
nodesCache = await api("/admin/api/nodes");
DD.ucGrantNode.setOptions(nodesCache.filter((n) => n.enabled).map((n) => ({ value: n.code, label: n.label })));
DD.ucGrantPlan.setOptions(plansCache.map((p) => ({ value: p.code, label: p.label })));
const data = await api(`/admin/api/users/${tgId}`);
document.getElementById("devices-limit").value = data.hwid_limit || "";
document.getElementById("devices-limit-hint").textContent = `По умолчанию (если пусто): ${data.hwid_fallback_limit}`;
renderUcSubs(data.subscriptions);
renderDevices(data.devices);
}
function closeDevices() {
document.getElementById("devices-overlay").classList.remove("show");
}
function switchUserTab(tab) {
document.querySelectorAll("[data-uc-tab]").forEach((t) => t.classList.toggle("active", t.dataset.ucTab === tab));
document.getElementById("uc-tab-subs").style.display = tab === "subs" ? "block" : "none";
document.getElementById("uc-tab-devices").style.display = tab === "devices" ? "block" : "none";
}
function renderUcSubs(subs) {
const list = document.getElementById("uc-subs-list");
list.innerHTML = subs.length ? subs.map((s, i) => `
<div class="uc-sub-row reveal" style="animation-delay:${Math.min(i, 10) * 0.025}s">
<div>
<div>${esc(s.node_label)} — ${esc(s.plan_label)}</div>
<div class="page-sub" style="margin:2px 0 0">до ${fmtDate(s.expires_at)} · ${statusBadge(s.active, s.days_left)}</div>
</div>
${s.active ? `<button class="muted-btn" onclick="revokeSub('${s.uuid}'); openUserCard(devicesTgId, document.getElementById('devices-title').textContent.replace('Карточка: ',''))">Отозвать</button>` : ""}
</div>
`).join("") : '<div class="empty">Пока нет подписок</div>';
}
async function grantSubscription() {
const node = DD.ucGrantNode.getValue();
const plan = DD.ucGrantPlan.getValue();
if (!node || !plan) return;
await api(`/admin/api/users/${devicesTgId}/grant`, { method: "POST", body: JSON.stringify({ node, plan }) });
openUserCard(devicesTgId, document.getElementById("devices-title").textContent.replace("Карточка: ", ""));
}
function renderDevices(devices) {
const list = document.getElementById("devices-list");
list.innerHTML = devices.length ? devices.map((d, i) => `
<div class="device-row reveal" style="animation-delay:${Math.min(i, 10) * 0.025}s">
<div>
<div>${esc(d.device_model || d.device_os || "Неизвестное устройство")}</div>
<div class="page-sub" style="margin:2px 0 0">${esc(d.device_os || "")} · с ${fmtDate(d.first_seen)}</div>
</div>
<button class="muted-btn" onclick="deleteDevice(${d.id})">Удалить</button>
</div>
`).join("") : '<div class="empty">Нет привязанных устройств</div>';
}
async function deleteDevice(deviceId) {
await api(`/admin/api/users/${devicesTgId}/devices/${deviceId}`, { method: "DELETE" });
const data = await api(`/admin/api/users/${devicesTgId}`);
renderDevices(data.devices);
}
async function saveHwidLimit() {
const val = document.getElementById("devices-limit").value.trim();
await api(`/admin/api/users/${devicesTgId}/hwid-limit`, { method: "POST", body: JSON.stringify({ limit: val || null }) });
}
async function revokeSub(uuid) {
if (!confirm("Отозвать подписку?")) return;
await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" });
loadSubscriptions();
}
let plansCache = null, nodesCache = null;
async function loadGifts() {
if (!plansCache) plansCache = await api("/admin/api/plans");
nodesCache = await api("/admin/api/nodes");
const nodeOptions = nodesCache.filter((n) => n.enabled).map((n) => ({ value: n.code, label: n.label }));
const planOptions = plansCache.map((p) => ({ value: p.code, label: p.label }));
DD.giftNode.setOptions(nodeOptions);
DD.giftPlan.setOptions(planOptions);
if (!DD.giftNode.getValue() && nodeOptions.length) DD.giftNode.setValue(nodeOptions[0].value);
if (!DD.giftPlan.getValue() && planOptions.length) DD.giftPlan.setValue(planOptions[0].value);
const codes = await api("/admin/api/gift-codes");
const body = document.getElementById("gifts-body");
body.innerHTML = codes.length ? codes.map((c, i) => `
<tr ${rowAttr(i)}><td>${esc(c.node_label)}</td><td>${esc(c.plan_label)}</td><td>${fmtDate(c.created_at)}</td>
<td>${c.used_by ? '<span class="badge bad">использован</span>' : '<span class="badge ok">свободен</span>'}</td>
<td><button class="muted-btn" onclick="copyText('${c.link}')">Скопировать</button></td></tr>
`).join("") : '<tr><td colspan="5"><div class="empty">Пока нет гифт-кодов</div></td></tr>';
}
async function createGift() {
const node = DD.giftNode.getValue();
const plan = DD.giftPlan.getValue();
const res = await api("/admin/api/gift-codes", { method: "POST", body: JSON.stringify({ node, plan }) });
document.getElementById("gift-result").innerHTML = `<div class="code-box" style="margin-top:12px">${res.link}<button class="copy-btn" onclick="copyText('${res.link}')">Копировать</button></div>`;
loadGifts();
}
function copyText(t) { navigator.clipboard.writeText(t); }
async function loadNodes() {
nodesCache = await api("/admin/api/nodes");
renderNodesTable();
}
let dragSrcCode = null;
function wireNodeDragAndDrop() {
const body = document.getElementById("nodes-body");
body.querySelectorAll("tr.draggable-row").forEach((row) => {
row.addEventListener("dragstart", (e) => {
dragSrcCode = row.dataset.code;
row.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
row.addEventListener("dragend", () => {
row.classList.remove("dragging");
body.querySelectorAll("tr").forEach((r) => r.classList.remove("drag-over"));
});
row.addEventListener("dragover", (e) => {
e.preventDefault();
if (row.dataset.code === dragSrcCode) return;
row.classList.add("drag-over");
});
row.addEventListener("dragleave", () => row.classList.remove("drag-over"));
row.addEventListener("drop", async (e) => {
e.preventDefault();
row.classList.remove("drag-over");
const targetCode = row.dataset.code;
if (!dragSrcCode || targetCode === dragSrcCode) return;
const order = nodesCache.map((n) => n.code);
const from = order.indexOf(dragSrcCode);
const to = order.indexOf(targetCode);
order.splice(to, 0, order.splice(from, 1)[0]);
nodesCache.sort((a, b) => order.indexOf(a.code) - order.indexOf(b.code));
renderNodesTable();
try {
await api("/admin/api/nodes/reorder", { method: "POST", body: JSON.stringify({ codes: order }) });
} catch (err) {
loadNodes();
}
});
});
}
function renderNodesTable() {
const body = document.getElementById("nodes-body");
body.innerHTML = nodesCache.map((n, i) => `
<tr class="reveal draggable-row" style="animation-delay:${Math.min(i, 10) * 0.025}s" draggable="true" data-code="${esc(n.code)}">
<td class="drag-handle" title="Перетащи, чтобы поменять порядок">⠿</td>
<td>${esc(n.label)}</td><td>${esc(n.address) || "—"}${n.port && n.address ? ":" + n.port : ""}</td>
<td>${n.kind === "local" ? "локальная" : n.kind === "managed" ? "управляемая" : "внешняя"}</td>
<td>${n.status === "pending" ? '<span class="badge warn">ожидает установки</span>' : (n.enabled ? '<span class="badge ok">включена</span>' : '<span class="badge bad">выключена</span>')}</td>
<td id="metrics-${esc(n.code)}">${n.status === "pending" ? "—" : `<button class="muted-btn" onclick="loadNodeMetrics('${esc(n.code)}')">Проверить</button>`}</td>
<td>
<button class="muted-btn" onclick="openEditNode('${n.code}')">Редактировать</button>
${n.code !== "de1" ? `<button class="muted-btn" onclick="toggleNode('${n.code}', ${n.enabled ? 0 : 1})">${n.enabled ? "Выключить" : "Включить"}</button>` : ""}
${n.code !== "de1" ? `<button class="muted-btn" onclick="deleteNode('${n.code}')">Удалить</button>` : ""}
</td>
</tr>
`).join("");
wireNodeDragAndDrop();
}
async function loadNodeMetrics(code) {
const cell = document.getElementById(`metrics-${code}`);
cell.textContent = "…";
try {
const m = await api(`/admin/api/nodes/${code}/metrics`);
if (!m.ok) { cell.innerHTML = '<span class="badge bad">офлайн</span>'; return; }
const load = m.load1 !== null && m.load1 !== undefined ? m.load1.toFixed(2) : "—";
cell.innerHTML = `<span style="font-family:'Fira Mono',monospace;font-size:12px">CPU ${load} · ${esc(m.mem_fmt)} · ${esc(m.uptime_fmt)}</span>`;
} catch (e) {
cell.innerHTML = '<span class="badge bad">ошибка</span>';
}
}
let editingNodeCode = null;
function openEditNode(code) {
const n = nodesCache.find((x) => x.code === code);
if (!n) return;
editingNodeCode = code;
const isDe1 = code === "de1";
document.getElementById("edit-node-title").textContent = `Редактировать: ${n.label}`;
document.getElementById("edit-label").value = n.label || "";
DD.editCountry.setValue(flagToCode(n.label));
document.getElementById("edit-node-advanced").style.display = isDe1 ? "none" : "block";
document.getElementById("edit-node-de1-note").style.display = isDe1 ? "block" : "none";
document.getElementById("edit-address").value = n.address || "";
document.getElementById("edit-port").value = n.port || 443;
document.getElementById("edit-sni").value = n.sni || "";
document.getElementById("edit-flow").value = n.flow || "";
document.getElementById("edit-pbk").value = n.public_key || "";
document.getElementById("edit-sid").value = n.short_id || "";
document.getElementById("edit-uuid").value = n.shared_uuid || "";
document.getElementById("edit-node-err").textContent = "";
document.getElementById("edit-node-overlay").classList.add("show");
}
function closeEditNode() {
document.getElementById("edit-node-overlay").classList.remove("show");
}
async function saveEditNode() {
const code = editingNodeCode;
if (!code) return;
const body = { label: document.getElementById("edit-label").value.trim() };
if (code !== "de1") {
body.address = document.getElementById("edit-address").value.trim();
body.port = parseInt(document.getElementById("edit-port").value || "443");
body.sni = document.getElementById("edit-sni").value.trim();
body.flow = document.getElementById("edit-flow").value.trim();
body.public_key = document.getElementById("edit-pbk").value.trim();
body.short_id = document.getElementById("edit-sid").value.trim();
body.shared_uuid = document.getElementById("edit-uuid").value.trim() || null;
}
try {
await api(`/admin/api/nodes/${code}`, { method: "PATCH", body: JSON.stringify(body) });
closeEditNode();
loadNodes();
} catch (e) {
document.getElementById("edit-node-err").innerHTML = '<p class="page-sub" style="color:var(--red);margin-top:8px">Не удалось сохранить</p>';
}
}
async function toggleNode(code, enabled) {
await api(`/admin/api/nodes/${code}`, { method: "PATCH", body: JSON.stringify({ enabled }) });
loadNodes();
}
async function deleteNode(code) {
if (!confirm("Удалить ноду?")) return;
await api(`/admin/api/nodes/${code}`, { method: "DELETE" });
loadNodes();
}
function switchNodeTab(tab) {
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === tab));
document.getElementById("node-tab-guide").style.display = tab === "guide" ? "block" : "none";
document.getElementById("node-tab-manual").style.display = tab === "manual" ? "block" : "none";
}
let pollTimer = null;
async function generateGuide() {
const label = document.getElementById("ng-label").value.trim();
const address = document.getElementById("ng-address").value.trim();
const port = parseInt(document.getElementById("ng-port").value || "443");
const sni = document.getElementById("ng-sni").value.trim();
const include_ws = document.getElementById("ng-ws").checked;
const include_hysteria2 = document.getElementById("ng-hy").checked;
const hysteria_port = parseInt(document.getElementById("ng-hy-port").value || "443");
if (!label || !address) return;
const res = await api("/admin/api/nodes/provision-guide", { method: "POST", body: JSON.stringify({ label, address, port, sni, include_ws, include_hysteria2, hysteria_port }) });
document.getElementById("guide-result").innerHTML = `
<p class="page-sub" style="margin:16px 0 8px">Выполни на новом сервере:</p>
<div class="code-box">${res.command}<button class="copy-btn" onclick="copyText('${res.command}')">Копировать</button></div>
<p class="page-sub" style="margin-top:12px" id="guide-status">Ожидаю установки…</p>
`;
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async () => {
const st = await api(`/admin/api/nodes/${res.code}/status`);
if (st.status === "active") {
clearInterval(pollTimer);
document.getElementById("guide-status").innerHTML = '<span class="badge ok">Установлено и подключено</span>';
loadNodes();
}
}, 4000);
}
async function createManualNode() {
const body = {
label: document.getElementById("nm-label").value.trim(),
code: document.getElementById("nm-code").value.trim(),
address: document.getElementById("nm-address").value.trim(),
port: parseInt(document.getElementById("nm-port").value || "443"),
public_key: document.getElementById("nm-pbk").value.trim(),
short_id: document.getElementById("nm-sid").value.trim(),
sni: document.getElementById("nm-sni").value.trim(),
shared_uuid: document.getElementById("nm-uuid").value.trim() || null,
kind: document.getElementById("nm-uuid").value.trim() ? "external" : "managed",
};
await api("/admin/api/nodes", { method: "POST", body: JSON.stringify(body) });
document.getElementById("manual-result").innerHTML = '<p class="page-sub" style="margin-top:12px">Нода добавлена.</p>';
loadNodes();
}
(async function init() {
initDropdowns();
try {
const me = await api("/admin/api/me");
if (me.authenticated) showApp(); else showLogin();
} catch (e) {
showLogin();
}
})();
</script>
</body>
</html>