mbs-panel/admin.html

3357 lines
207 KiB
HTML
Raw Normal View History

<!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="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%2322d3ee'/%3E%3Cpath d='M10 20V12M16 23V9M22 18v-4' stroke='%23031116' stroke-width='2.6' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #07090d; --bg-2: #0b0e14; --sidebar: #06080c;
--surface: #0e1219; --surface-2: #131923; --surface-3: #1a212c;
--card-tint: rgba(255,255,255,0.028); --card2-tint: rgba(255,255,255,0.055);
--border: rgba(255,255,255,0.07); --border-strong: rgba(255,255,255,0.14);
--text: #e7ebf1; --text-dim: #b5bdca; --muted: #8791a2; --muted2: #5e6a7c;
--accent-rgb: 34,211,238; --accent-deep-rgb: 8,145,178;
--accent: rgb(var(--accent-rgb)); --accent-deep: rgb(var(--accent-deep-rgb));
--accent-dim: rgba(var(--accent-rgb), 0.12); --accent-border: rgba(var(--accent-rgb), 0.38);
--green: #3ddc84; --red: #ff5d6c; --yellow: #f5b83d; --blue: #5aa9ff; --pink: #f06ab0;
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--radius: 10px;
--mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
--shadow-card: 0 1px 0 rgba(255,255,255,0.045) inset, 0 18px 40px -24px rgba(0,0,0,0.9);
--shadow-pop: 0 28px 80px -20px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06);
}
* { box-sizing: border-box; }
html { color-scheme: dark; }
body {
margin: 0; color: var(--text); min-height: 100vh;
background:
radial-gradient(1100px 520px at 8% -12%, rgba(var(--accent-rgb), 0.075), transparent 62%),
radial-gradient(900px 480px at 104% 4%, rgba(130, 100, 255, 0.05), transparent 58%),
var(--bg);
background-attachment: fixed;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased; font-size: 14px; font-feature-settings: "cv11", "ss01";
}
* { 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(--muted2); background-clip: padding-box; }
::selection { background: rgba(var(--accent-rgb), 0.3); }
button, input, select { font-family: inherit; font-size: 14px; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 3px; }
code, .mono { font-family: var(--mono); }
:focus-visible { outline: 2px solid rgba(var(--accent-rgb), 0.7); outline-offset: 2px; border-radius: 6px; }
#login-screen {
min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
position: relative; overflow: hidden;
}
#login-screen::before, #login-screen::after {
content: ""; position: absolute; border-radius: 50%; filter: blur(70px); opacity: 0.55; pointer-events: none;
}
#login-screen::before { width: 460px; height: 460px; left: 12%; top: 8%; background: rgba(var(--accent-rgb), 0.16); animation: orbA 14s ease-in-out infinite alternate; }
#login-screen::after { width: 380px; height: 380px; right: 10%; bottom: 6%; background: rgba(130, 100, 255, 0.14); animation: orbB 17s ease-in-out infinite alternate; }
@keyframes orbA { to { transform: translate(60px, 40px) scale(1.15); } }
@keyframes orbB { to { transform: translate(-50px, -30px) scale(1.1); } }
.login-card {
position: relative; z-index: 1;
max-width: 360px; width: 100%; padding: 34px 30px;
background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02));
border: 1px solid var(--border-strong); border-radius: 18px;
backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
box-shadow: var(--shadow-pop);
opacity: 0; animation: fadeIn 0.3s 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: 24px; }
.splash-mark {
width: 48px; height: 48px; border-radius: 14px; margin-bottom: 14px;
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
box-shadow: 0 10px 34px -8px rgba(var(--accent-rgb), 0.7);
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: 26px; height: 26px; }
.splash-title {
font-size: 20px; font-weight: 700; letter-spacing: -0.02em;
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.06em; margin-top: 5px;
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], input[type=number] {
width: 100%; background: rgba(255,255,255,0.035); border: 1px solid var(--border); border-radius: var(--radius);
padding: 11px 13px; color: var(--text); margin-bottom: 10px;
transition: border-color 0.2s var(--ease), box-shadow 0.2s var(--ease), background 0.2s var(--ease);
}
.form-row input[type=text], .form-row input[type=password], .section input[type=text], .section input[type=password] { margin-bottom: 0; }
input:hover { border-color: var(--border-strong); }
input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.16); background: rgba(255,255,255,0.05); }
input::placeholder { color: var(--muted2); }
.btn {
position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 10px 18px; border-radius: var(--radius); border: 1px solid transparent; cursor: pointer;
background: linear-gradient(135deg, var(--accent), var(--accent-deep)); color: #031116;
font-weight: 650; letter-spacing: -0.005em;
box-shadow: 0 10px 28px -12px rgba(var(--accent-rgb), 0.65), 0 1px 0 rgba(255,255,255,0.25) inset;
transition: transform 0.18s var(--ease), box-shadow 0.25s var(--ease), filter 0.2s var(--ease), background 0.2s ease;
}
.btn:hover { filter: brightness(1.08); transform: translateY(-1px); box-shadow: 0 14px 34px -12px rgba(var(--accent-rgb), 0.8), 0 1px 0 rgba(255,255,255,0.3) inset; }
.btn:active { transform: scale(0.97); filter: brightness(0.95); }
.btn:disabled, .btn.disabled { opacity: 0.4; cursor: not-allowed; transform: none; filter: grayscale(0.4); box-shadow: none; }
.btn.block { width: 100%; margin-top: 14px; }
.btn.ghost { background: rgba(255,255,255,0.03); color: var(--text-dim); border: 1px solid var(--border); box-shadow: none; }
.btn.ghost:hover { color: var(--text); border-color: var(--border-strong); background: rgba(255,255,255,0.07); filter: none; }
.btn.danger { background: rgba(255,93,108,0.1); color: var(--red); border: 1px solid rgba(255,93,108,0.32); box-shadow: none; }
.btn.danger:hover { background: rgba(255,93,108,0.2); filter: none; }
.btn.small { padding: 6px 12px; font-size: 12.5px; border-radius: 8px; }
.btn.loading { pointer-events: none; color: transparent !important; }
.btn.loading::after {
content: ""; position: absolute; width: 16px; height: 16px; border-radius: 50%;
border: 2px solid rgba(3,17,22,0.35); border-top-color: #031116; animation: spin 0.7s linear infinite;
}
.btn.ghost.loading::after { border-color: rgba(255,255,255,0.2); border-top-color: var(--text); }
@keyframes spin { to { transform: rotate(360deg); } }
#login-err { color: var(--red); font-size: 13px; min-height: 16px; margin-top: 10px; }
#app { display: none; min-height: 100vh; grid-template-columns: 252px 1fr; }
#app.show { display: grid; }
.sidebar {
background: linear-gradient(180deg, rgba(6,8,12,0.96), rgba(6,8,12,0.88));
border-right: 1px solid var(--border); padding: 16px 12px;
display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; overflow-y: auto;
}
.brand { display: flex; align-items: center; gap: 11px; font-weight: 700; font-size: 15px; letter-spacing: -0.015em; padding: 8px 10px 20px; }
.brand .mark {
width: 28px; height: 28px; border-radius: 9px; flex: none;
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
box-shadow: 0 6px 20px -6px rgba(var(--accent-rgb), 0.7);
display: flex; align-items: center; justify-content: center;
}
.brand .mark svg { width: 15px; height: 15px; }
.nav-label { font-size: 10.5px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted2); padding: 16px 12px 7px; }
.nav-item {
position: relative; display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: 9px;
color: var(--muted); cursor: pointer; margin-bottom: 2px; font-size: 13.5px; font-weight: 500;
transition: background 0.2s var(--ease), color 0.2s var(--ease), transform 0.2s var(--ease);
}
.nav-item svg { width: 17px; height: 17px; flex: none; opacity: 0.8; transition: transform 0.25s var(--ease), opacity 0.2s; }
.nav-item:hover { background: var(--card2-tint); color: var(--text); }
.nav-item:hover svg { transform: scale(1.08); opacity: 1; }
.nav-item.active { background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-rgb), 0.04)); color: var(--accent); }
.nav-item.active svg { opacity: 1; }
.nav-item::before {
content: ""; position: absolute; left: -12px; top: 50%; width: 3px; height: 0; border-radius: 0 3px 3px 0;
background: var(--accent); transform: translateY(-50%); transition: height 0.3s var(--ease); box-shadow: 0 0 14px rgba(var(--accent-rgb), 0.9);
}
.nav-item.active::before { height: 18px; }
.nav-item .nav-count {
margin-left: auto; font-family: var(--mono); font-size: 10.5px; padding: 1px 7px; border-radius: 999px;
background: rgba(255,255,255,0.07); color: var(--text-dim);
}
.sidebar-footer { margin-top: auto; padding: 14px 0 0; }
.version-tag { text-align: center; font-size: 11px; color: var(--muted2); margin-top: 10px; font-family: var(--mono); }
.main { min-width: 0; padding: 0 40px 60px; }
.topbar {
position: sticky; top: 0; z-index: 30; margin: 0 -40px 26px; padding: 14px 40px;
display: flex; align-items: center; gap: 14px;
background: linear-gradient(180deg, rgba(7,9,13,0.92), rgba(7,9,13,0.72));
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
}
.crumbs { font-size: 12.5px; color: var(--muted); display: flex; align-items: center; gap: 8px; }
.crumbs b { color: var(--text); font-weight: 600; }
.crumbs .sep { opacity: 0.4; }
.topbar-spacer { flex: 1; }
.search-pill {
display: flex; align-items: center; gap: 10px; min-width: 240px; padding: 8px 12px; border-radius: 10px;
background: rgba(255,255,255,0.035); border: 1px solid var(--border); color: var(--muted); cursor: pointer;
font-size: 13px; transition: border-color 0.2s var(--ease), background 0.2s var(--ease), color 0.2s var(--ease);
}
.search-pill:hover { border-color: var(--border-strong); background: rgba(255,255,255,0.06); color: var(--text-dim); }
.search-pill svg { width: 15px; height: 15px; }
.kbd {
margin-left: auto; font-family: var(--mono); font-size: 10.5px; padding: 2px 6px; border-radius: 5px;
background: rgba(255,255,255,0.07); border: 1px solid var(--border); color: var(--text-dim);
}
.accent-dots { display: flex; gap: 7px; align-items: center; }
.accent-dot {
width: 15px; height: 15px; border-radius: 50%; cursor: pointer; border: 2px solid transparent;
transition: transform 0.2s var(--ease), border-color 0.2s var(--ease), box-shadow 0.2s var(--ease);
}
.accent-dot:hover { transform: scale(1.2); }
.accent-dot.active { border-color: #fff; box-shadow: 0 0 0 2px rgba(255,255,255,0.12); }
.live-pill { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); font-family: var(--mono); }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 0 rgba(61,220,132,0.6); animation: livePulse 2s ease-out infinite; }
@keyframes livePulse { 0% { box-shadow: 0 0 0 0 rgba(61,220,132,0.55); } 70% { box-shadow: 0 0 0 9px rgba(61,220,132,0); } 100% { box-shadow: 0 0 0 0 rgba(61,220,132,0); } }
.content { max-width: 1180px; margin: 0 auto; }
.page-title { font-size: 24px; font-weight: 700; margin: 0 0 6px; letter-spacing: -0.025em; }
.page-sub { color: var(--muted); font-size: 13.5px; margin: 0 0 26px; line-height: 1.55; }
.view { display: none; }
.view.active { display: block; animation: viewIn 0.45s var(--ease); }
@keyframes viewIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.reveal { opacity: 0; animation: fadeIn 0.3s linear forwards; }
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 28px; }
.stat-card {
position: relative; padding: 16px 18px 18px; border-radius: 14px; overflow: hidden;
background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.016));
border: 1px solid var(--border); box-shadow: var(--shadow-card);
transition: transform 0.3s var(--ease), border-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
}
.stat-card::after {
content: ""; position: absolute; inset: auto -30% -70% auto; width: 160px; height: 160px; border-radius: 50%;
background: radial-gradient(circle, rgba(var(--accent-rgb), 0.16), transparent 65%); opacity: 0; transition: opacity 0.4s var(--ease);
}
.stat-card:hover { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 22px 44px -22px rgba(0,0,0,0.95); }
.stat-card:hover::after { opacity: 1; }
.stat-card > * { position: relative; z-index: 1; }
.stat-card .l { color: var(--muted); font-size: 12px; margin-bottom: 14px; font-weight: 500; }
.stat-card .row { display: flex; align-items: center; gap: 12px; }
.stat-card .ico { width: 34px; height: 34px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.05); flex: none; }
.stat-card svg { width: 17px; height: 17px; flex: none; }
.stat-card .v { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.03em; font-family: var(--mono); line-height: 1; }
.stat-card .sub { font-size: 11.5px; color: var(--muted2); margin-top: 10px; font-family: var(--mono); }
.panel {
position: relative; border-radius: 14px; padding: 18px 20px;
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.014));
border: 1px solid var(--border); box-shadow: var(--shadow-card);
}
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; gap: 10px; }
.panel-head h2 { font-size: 14px; margin: 0; font-weight: 650; letter-spacing: -0.01em; }
.grid-2 { display: grid; grid-template-columns: 1.15fr 1fr; gap: 14px; margin-bottom: 28px; }
table { width: 100%; border-collapse: collapse; }
.table-wrap {
background: linear-gradient(180deg, rgba(255,255,255,0.035), rgba(255,255,255,0.012));
border: 1px solid var(--border); border-radius: 14px; overflow: hidden; box-shadow: var(--shadow-card);
overflow-x: auto;
}
th {
text-align: left; font-size: 11px; color: var(--muted2); font-weight: 600;
padding: 12px 16px; border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.08em;
background: rgba(255,255,255,0.015); white-space: nowrap;
}
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), box-shadow 0.2s var(--ease); }
tbody tr:hover { background: var(--card2-tint); box-shadow: inset 2px 0 0 var(--accent); }
.badge {
display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 11.5px; font-weight: 600;
border: 1px solid; background: transparent; white-space: nowrap;
}
.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.badge.ok { border-color: rgba(61,220,132,0.32); color: var(--green); background: rgba(61,220,132,0.07); }
.badge.bad { border-color: rgba(255,93,108,0.32); color: var(--red); background: rgba(255,93,108,0.07); }
.badge.warn { border-color: rgba(245,184,61,0.34); color: var(--yellow); background: rgba(245,184,61,0.07); }
.badge.info { border-color: rgba(var(--accent-rgb), 0.34); color: var(--accent); background: rgba(var(--accent-rgb), 0.07); }
.badge.plain::before { display: none; }
.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: 650; letter-spacing: -0.01em; }
.check-row { display: flex; flex-direction: column; gap: 10px; margin: 6px 0 16px; }
.check { display: flex; align-items: flex-start; gap: 10px; font-size: 13.5px; cursor: pointer; }
.check input[type=checkbox] {
appearance: none; -webkit-appearance: none; flex: none; width: 18px; height: 18px; margin: 1px 0 0; cursor: pointer;
border-radius: 6px; border: 1.5px solid var(--border-strong); background: rgba(255,255,255,0.03);
display: grid; place-content: center; transition: background 0.2s var(--ease), border-color 0.2s var(--ease), transform 0.15s var(--ease);
}
.check input[type=checkbox]::after {
content: ""; width: 9px; height: 5px; border-left: 2px solid #031116; border-bottom: 2px solid #031116;
transform: rotate(-45deg) scale(0); margin-top: -2px; transition: transform 0.2s var(--ease);
}
.check input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
.check input[type=checkbox]:checked::after { transform: rotate(-45deg) scale(1); }
.check input[type=checkbox]:active { transform: scale(0.9); }
.check-hint { color: var(--muted); font-size: 12px; }
.form-row { display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
.form-row > * { flex: 1; min-width: 140px; }
label.f { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; font-weight: 500; }
.switch { position: relative; display: inline-block; width: 38px; height: 22px; flex: none; }
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.switch .track {
position: absolute; inset: 0; border-radius: 999px; background: rgba(255,255,255,0.09); border: 1px solid var(--border-strong);
cursor: pointer; transition: background 0.25s var(--ease), border-color 0.25s var(--ease);
}
.switch .track::after {
content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #cfd6e0;
transition: transform 0.3s var(--ease), background 0.2s ease;
}
.switch input:checked + .track { background: rgba(var(--accent-rgb), 0.28); border-color: var(--accent-border); }
.switch input:checked + .track::after { transform: translateX(16px); background: var(--accent); box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.8); }
.switch input:focus-visible + .track { outline: 2px solid rgba(var(--accent-rgb), 0.7); outline-offset: 2px; }
.dd { position: relative; }
.dd-trigger {
width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px;
background: rgba(255,255,255,0.035); 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), box-shadow 0.2s var(--ease), background 0.2s var(--ease);
}
.dd-trigger:hover { border-color: var(--border-strong); background: rgba(255,255,255,0.055); }
.dd.dd-open .dd-trigger { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.16); }
.dd-trigger-label.placeholder { color: var(--muted); }
.dd-chevron { width: 15px; height: 15px; color: var(--muted); flex: none; transition: transform 0.25s var(--ease); }
.dd.dd-open .dd-chevron { transform: rotate(180deg); }
.dd-menu {
position: absolute; top: calc(100% + 8px); left: 0; right: 0; z-index: 60;
background: #121823; border: 1px solid var(--border-strong); border-radius: 12px;
padding: 6px; max-height: 270px; overflow: hidden; display: flex; flex-direction: column;
box-shadow: var(--shadow-pop);
opacity: 0; transform: translateY(-8px) scale(0.97); filter: blur(3px);
pointer-events: none; transition: opacity 0.18s var(--ease), transform 0.18s var(--ease), filter 0.18s 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.035); border: 1px solid var(--border); border-radius: 8px;
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: 8px; cursor: pointer; font-size: 13.5px; transition: background 0.12s var(--ease), padding-left 0.2s var(--ease); }
.dd-option:hover { background: var(--card2-tint); padding-left: 14px; }
.dd-option.selected { color: var(--accent); background: var(--accent-dim); }
.dd-empty { padding: 10px; color: var(--muted); font-size: 13px; text-align: center; }
.tabs { position: relative; display: flex; gap: 4px; margin-bottom: 18px; background: rgba(255,255,255,0.03); border: 1px solid var(--border); padding: 4px; border-radius: 12px; width: fit-content; }
.tab { padding: 8px 16px; border-radius: 9px; cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 500; transition: all 0.25s var(--ease); }
.tab:hover { color: var(--text); }
.tab.active { background: rgba(255,255,255,0.08); color: var(--text); box-shadow: 0 1px 0 rgba(255,255,255,0.08) inset, 0 6px 16px -8px rgba(0,0,0,0.8); }
.code-box {
background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 76px 14px 14px;
font-family: var(--mono); 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: 650; letter-spacing: -0.015em; }
.doc-block h3 { font-size: 14px; margin: 18px 0 8px; font-weight: 650; }
.doc-block p { font-size: 13.5px; color: var(--text-dim); line-height: 1.7; margin: 0 0 10px; }
.doc-block code { background: rgba(255,255,255,0.07); padding: 1px 6px; border-radius: 5px; 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: 7px; padding: 4px 9px; font-size: 11px; cursor: pointer; transition: all 0.15s;
}
.copy-btn:hover { color: var(--text); border-color: var(--border-strong); }
.muted-btn { background: none; border: 1px solid transparent; color: var(--muted); cursor: pointer; padding: 4px 9px; border-radius: 7px; font-size: 12.5px; transition: all 0.15s ease; }
.muted-btn:hover { color: var(--accent); border-color: var(--accent-border); background: var(--accent-dim); }
.muted-btn.red:hover { color: var(--red); border-color: rgba(255,93,108,0.35); background: rgba(255,93,108,0.08); }
.empty { text-align: center; color: var(--muted); padding: 44px 0; font-size: 13px; }
.drag-handle { cursor: grab; color: var(--muted2); text-align: center; user-select: none; font-size: 15px; transition: color 0.15s; }
tr:hover .drag-handle { color: var(--accent); }
.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(2,4,8,0.66); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 100;
opacity: 0; pointer-events: none; transition: opacity 0.2s var(--ease);
}
.modal-overlay.show { opacity: 1; pointer-events: auto; }
.modal-card {
width: 100%; max-width: 480px; max-height: 88vh; overflow-y: auto;
background: linear-gradient(180deg, #151b26, #10151e); border: 1px solid var(--border-strong); border-radius: 18px; padding: 24px;
box-shadow: var(--shadow-pop);
opacity: 0; transform: translateY(14px) scale(0.97); filter: blur(4px);
transition: opacity 0.25s var(--ease), transform 0.3s var(--ease), filter 0.25s var(--ease);
}
.modal-card-lg { max-width: 640px; }
.uc-sub-row {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px; margin-bottom: 8px;
font-size: 13.5px; background: rgba(255,255,255,0.02);
}
.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: 18px; }
.modal-head h2 { font-size: 16px; margin: 0; font-weight: 650; }
.modal-close { background: none; border: none; color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; padding: 2px 8px; border-radius: 7px; transition: all 0.15s; }
.modal-close:hover { color: var(--text); background: var(--card2-tint); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
.device-row {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px; margin-bottom: 8px;
font-size: 13.5px; background: rgba(255,255,255,0.02);
}
.devices-list { max-height: 260px; overflow-y: auto; margin: 4px 0 16px; }
#toast-stack { position: fixed; right: 22px; bottom: 22px; z-index: 300; display: flex; flex-direction: column; gap: 10px; pointer-events: none; }
.toast {
position: relative; overflow: hidden; pointer-events: auto; display: flex; align-items: flex-start; gap: 12px;
min-width: 280px; max-width: 400px; padding: 13px 16px; border-radius: 12px;
background: rgba(18,24,35,0.94); border: 1px solid var(--border-strong); box-shadow: var(--shadow-pop);
backdrop-filter: blur(14px); font-size: 13.5px; line-height: 1.45;
animation: toastIn 0.45s var(--ease) both;
}
.toast.out { animation: toastOut 0.3s var(--ease) forwards; }
.toast .t-ico { width: 20px; height: 20px; flex: none; margin-top: 1px; }
.toast.success .t-ico { color: var(--green); }
.toast.error .t-ico { color: var(--red); }
.toast.info .t-ico { color: var(--accent); }
.toast.warn .t-ico { color: var(--yellow); }
.toast .t-bar { position: absolute; left: 0; bottom: 0; height: 2px; background: currentColor; opacity: 0.7; animation: toastBar linear forwards; }
.toast.success .t-bar { color: var(--green); } .toast.error .t-bar { color: var(--red); } .toast.info .t-bar { color: var(--accent); } .toast.warn .t-bar { color: var(--yellow); }
@keyframes toastIn { from { opacity: 0; transform: translateX(30px) scale(0.96); } to { opacity: 1; transform: none; } }
@keyframes toastOut { to { opacity: 0; transform: translateX(30px) scale(0.96); } }
@keyframes toastBar { from { width: 100%; } to { width: 0; } }
#cmdk-overlay {
position: fixed; inset: 0; z-index: 250; display: none; align-items: flex-start; justify-content: center; padding-top: 14vh;
background: rgba(2,4,8,0.6); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
}
#cmdk-overlay.show { display: flex; animation: fadeIn 0.18s linear; }
.cmdk {
width: min(620px, calc(100vw - 32px)); border-radius: 16px; overflow: hidden;
background: linear-gradient(180deg, #151c28, #0f141d); border: 1px solid var(--border-strong); box-shadow: var(--shadow-pop);
animation: cmdkIn 0.3s var(--ease);
}
@keyframes cmdkIn { from { opacity: 0; transform: translateY(-14px) scale(0.97); } to { opacity: 1; transform: none; } }
.cmdk-input-wrap { display: flex; align-items: center; gap: 12px; padding: 4px 18px; border-bottom: 1px solid var(--border); }
.cmdk-input-wrap svg { width: 18px; height: 18px; color: var(--muted); flex: none; }
#cmdk-input { background: transparent !important; border: none !important; box-shadow: none !important; margin: 0 !important; padding: 15px 0 !important; font-size: 15px; }
.cmdk-list { max-height: 360px; overflow-y: auto; padding: 8px; }
.cmdk-group { font-size: 10.5px; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted2); padding: 10px 12px 6px; }
.cmdk-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; cursor: pointer; font-size: 13.5px; color: var(--text-dim); }
.cmdk-item svg { width: 16px; height: 16px; flex: none; color: var(--muted); }
.cmdk-item .hint { margin-left: auto; font-size: 11.5px; color: var(--muted2); }
.cmdk-item.sel { background: var(--accent-dim); color: var(--text); }
.cmdk-item.sel svg { color: var(--accent); }
.cmdk-foot { display: flex; gap: 16px; padding: 10px 18px; border-top: 1px solid var(--border); font-size: 11.5px; color: var(--muted2); }
.skeleton { position: relative; overflow: hidden; background: rgba(255,255,255,0.045); border-radius: 8px; min-height: 14px; }
.skeleton::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.07), transparent); animation: shimmer 1.4s infinite; }
@keyframes shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
.pill-ms { font-family: var(--mono); font-size: 11.5px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--text-dim); white-space: nowrap; }
.pill-ms.good { color: var(--green); border-color: rgba(61,220,132,0.3); background: rgba(61,220,132,0.07); }
.pill-ms.mid { color: var(--yellow); border-color: rgba(245,184,61,0.32); background: rgba(245,184,61,0.07); }
.pill-ms.bad { color: var(--red); border-color: rgba(255,93,108,0.32); background: rgba(255,93,108,0.07); }
.net-row { display: flex; align-items: center; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--border); }
.net-row:last-child { border-bottom: none; }
.net-row .name { font-weight: 600; font-size: 13.5px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.net-row .addr { font-family: var(--mono); font-size: 11.5px; color: var(--muted2); }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted2); flex: none; }
.dot.on { background: var(--green); box-shadow: 0 0 10px rgba(61,220,132,0.7); }
.dot.off { background: var(--red); box-shadow: 0 0 10px rgba(255,93,108,0.6); }
.dot.wait { background: var(--yellow); animation: livePulse 1.6s ease-out infinite; }
.audit-pill { font-family: var(--mono); font-size: 11px; padding: 3px 9px; border-radius: 7px; background: rgba(255,255,255,0.06); color: var(--text-dim); white-space: nowrap; }
.audit-pill.login { background: rgba(var(--accent-rgb), 0.12); color: var(--accent); }
.audit-pill.warn { background: rgba(255,93,108,0.12); color: var(--red); }
.audit-pill.node { background: rgba(90,169,255,0.12); color: var(--blue); }
.audit-pill.chain { background: rgba(245,184,61,0.12); color: var(--yellow); }
.audit-pill.sub { background: rgba(61,220,132,0.1); color: var(--green); }
.audit-pill.settings { background: rgba(240,106,176,0.12); color: var(--pink); }
.cb-layout { display: block; margin-bottom: 30px; }
.cb-scroll { overflow-x: auto; border-radius: 18px; }
.cb-canvas {
position: relative; min-width: 760px; min-height: 440px; padding: 26px 22px; border-radius: 18px; overflow: hidden;
border: 1px solid var(--border-strong);
background:
radial-gradient(circle at 50% 50%, rgba(var(--accent-rgb), 0.07), transparent 60%),
radial-gradient(circle, rgba(255,255,255,0.075) 1px, transparent 1.4px) 0 0 / 22px 22px,
linear-gradient(180deg, #0c1119, #090d13);
box-shadow: var(--shadow-card), inset 0 0 90px rgba(0,0,0,0.55);
display: flex; align-items: center; justify-content: space-between; gap: 26px; user-select: none; touch-action: none;
}
.cb-canvas.dragging { cursor: grabbing; }
.cb-wires { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 4; overflow: visible; }
.cb-wire { fill: none; stroke: rgba(var(--accent-rgb), 0.9); stroke-width: 2.5; stroke-linecap: round; filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.7)); }
.cb-wire.flow { stroke-dasharray: 9 9; animation: wireFlow 0.9s linear infinite; }
.cb-wire.warn { stroke: var(--yellow); filter: drop-shadow(0 0 7px rgba(245,184,61,0.75)); }
.cb-wire.high { stroke: var(--red); filter: drop-shadow(0 0 7px rgba(255,93,108,0.75)); }
.cb-wire.live { stroke-dasharray: 3 7; opacity: 0.85; animation: wireFlow 0.5s linear infinite; }
.cb-wire.retract { animation: wireRetract 0.35s var(--ease) forwards; }
.cb-wire-under { fill: none; stroke: rgba(255,255,255,0.06); stroke-width: 7; stroke-linecap: round; }
.cb-packet { fill: #fff; filter: drop-shadow(0 0 6px rgba(255,255,255,0.95)); }
@keyframes wireFlow { to { stroke-dashoffset: -18; } }
@keyframes wireRetract { to { opacity: 0; stroke-width: 0.5; } }
.cb-node {
position: relative; z-index: 2; border-radius: 14px; padding: 12px 14px; display: flex; align-items: center; gap: 11px;
background: linear-gradient(180deg, #161d29, #111722); border: 1px solid var(--border-strong);
box-shadow: 0 14px 30px -16px rgba(0,0,0,0.9), 0 1px 0 rgba(255,255,255,0.05) inset;
transition: transform 0.3s var(--ease), border-color 0.25s var(--ease), box-shadow 0.3s var(--ease), opacity 0.25s;
}
.cb-node .ico { width: 38px; height: 38px; border-radius: 11px; display: flex; align-items: center; justify-content: center; flex: none; background: rgba(255,255,255,0.06); font-size: 20px; }
.cb-node .ico svg { width: 20px; height: 20px; }
.cb-node .nm { font-weight: 650; font-size: 13.5px; line-height: 1.25; }
.cb-node .ad { font-family: var(--mono); font-size: 10.5px; color: var(--muted2); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
.cb-endpoint { flex-direction: column; text-align: center; padding: 18px 16px; min-width: 118px; gap: 9px; }
.cb-endpoint .ico { width: 50px; height: 50px; border-radius: 15px; }
.cb-endpoint .ico svg { width: 26px; height: 26px; }
.cb-client { cursor: grab; }
.cb-client .ico { background: rgba(var(--accent-rgb), 0.14); color: var(--accent); }
.cb-internet .ico { background: rgba(90,169,255,0.14); color: var(--blue); }
.cb-pool-wrap { flex: 1; min-width: 0; align-self: stretch; display: flex; flex-direction: column; }
.cb-pool-label { font-size: 10.5px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted2); text-align: center; margin-bottom: 10px; }
.cb-pool {
flex: 1; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; align-content: center;
padding: 16px; border-radius: 16px; border: 1.5px dashed rgba(255,255,255,0.1); background: rgba(255,255,255,0.012);
}
.cb-server { cursor: pointer; }
.cb-server:hover { transform: translateY(-3px); border-color: rgba(var(--accent-rgb), 0.5); }
.cb-server.in-chain { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 34px -4px rgba(var(--accent-rgb), 0.55), 0 14px 30px -16px rgba(0,0,0,0.9); animation: snapPop 0.45s var(--ease); }
.cb-server.target { border-color: #fff; transform: translateY(-4px) scale(1.04); box-shadow: 0 0 0 1px #fff, 0 0 40px rgba(255,255,255,0.3); }
.cb-node.target::after { content: ""; position: absolute; left: 12px; right: 12px; bottom: 5px; height: 3px; border-radius: 3px; background: var(--accent); box-shadow: 0 0 10px var(--accent); transform-origin: left; animation: charge 0.24s linear forwards; }
@keyframes charge { from { transform: scaleX(0); } to { transform: scaleX(1); } }
.cb-server.locked { opacity: 0.4; cursor: not-allowed; filter: grayscale(0.7); }
.cb-server.shake { animation: shake 0.45s var(--ease); border-color: var(--red); }
.cb-internet.target { border-color: #fff; transform: scale(1.06); box-shadow: 0 0 0 1px #fff, 0 0 40px rgba(255,255,255,0.3); }
.cb-internet.linked { border-color: var(--blue); box-shadow: 0 0 0 1px var(--blue), 0 0 34px -4px rgba(90,169,255,0.55); }
.cb-client.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 34px -4px rgba(var(--accent-rgb), 0.55); }
@keyframes snapPop { 0% { transform: scale(0.94); } 55% { transform: scale(1.06); } 100% { transform: scale(1); } }
@keyframes shake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-7px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(4px); } }
.cb-badge {
position: absolute; top: -9px; left: -9px; width: 22px; height: 22px; border-radius: 50%; display: none; align-items: center; justify-content: center;
background: var(--accent); color: #031116; font-size: 11.5px; font-weight: 800; font-family: var(--mono); box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.6);
}
.cb-server.in-chain .cb-badge { display: flex; animation: snapPop 0.45s var(--ease); }
.cb-x { position: absolute; top: -9px; right: -9px; width: 22px; height: 22px; border-radius: 50%; display: none; align-items: center; justify-content: center; background: #2a3140; color: var(--text); font-size: 13px; cursor: pointer; border: 1px solid var(--border-strong); }
.cb-server.in-chain .cb-x { display: flex; }
.cb-x:hover { background: var(--red); color: #fff; }
.cb-port {
position: absolute; top: 50%; width: 14px; height: 14px; border-radius: 50%; transform: translateY(-50%);
background: #0b0f16; border: 2px solid var(--muted2); transition: all 0.25s var(--ease); z-index: 3;
}
.cb-port.out { right: -8px; } .cb-port.in { left: -8px; }
.cb-node.in-chain .cb-port, .cb-client.active .cb-port.out, .cb-internet.linked .cb-port { border-color: var(--accent); background: var(--accent); box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.9); }
.cb-tail-port { cursor: crosshair; }
.cb-node.tail .cb-port.out { animation: portPulse 1.4s ease-out infinite; border-color: var(--accent); }
@keyframes portPulse { 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.7); } 100% { box-shadow: 0 0 0 12px rgba(var(--accent-rgb), 0); } }
.cb-lat { font-family: var(--mono); font-size: 10px; margin-top: 4px; color: var(--muted2); }
.cb-hint { text-align: center; margin-top: 12px; font-size: 12px; color: var(--muted); }
.cb-hint b { color: var(--accent); font-weight: 600; }
.cb-inspector { margin-top: 16px; }
.cb-inspector-body { display: grid; grid-template-columns: 220px minmax(0, 1fr) 300px; gap: 28px; align-items: start; }
.cb-steps { display: flex; flex-direction: column; gap: 0; margin: 4px 0 16px; }
.cb-step { display: flex; align-items: center; gap: 12px; padding: 9px 0; font-size: 13px; color: var(--muted); position: relative; }
.cb-step:not(:last-child)::after { content: ""; position: absolute; left: 10px; top: 32px; width: 2px; height: calc(100% - 14px); background: var(--border-strong); }
.cb-step .st { width: 22px; height: 22px; border-radius: 50%; flex: none; display: flex; align-items: center; justify-content: center; border: 1.5px solid var(--border-strong); background: var(--surface); font-size: 11px; font-family: var(--mono); z-index: 1; transition: all 0.3s var(--ease); }
.cb-step.done { color: var(--text); }
.cb-step.done .st { background: var(--accent); border-color: var(--accent); color: #031116; box-shadow: 0 0 14px rgba(var(--accent-rgb), 0.6); }
.cb-step.done:not(:last-child)::after { background: var(--accent); }
.cb-callout { display: flex; gap: 11px; padding: 12px 14px; border-radius: 12px; font-size: 12.5px; line-height: 1.5; margin-bottom: 14px; border: 1px solid; animation: calloutIn 0.45s var(--ease); }
.cb-callout svg { width: 18px; height: 18px; flex: none; margin-top: 1px; }
.cb-callout.info { color: var(--text-dim); border-color: var(--border-strong); background: rgba(255,255,255,0.03); }
.cb-callout.warn { color: #ffd98a; border-color: rgba(245,184,61,0.4); background: rgba(245,184,61,0.08); }
.cb-callout.high { color: #ffb3ba; border-color: rgba(255,93,108,0.45); background: rgba(255,93,108,0.09); }
.cb-callout.ok { color: #a6f0c4; border-color: rgba(61,220,132,0.35); background: rgba(61,220,132,0.07); }
@keyframes calloutIn { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
.chain-card { display: flex; align-items: center; gap: 18px; padding: 16px 18px; border-radius: 14px; margin-bottom: 12px; background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.014)); border: 1px solid var(--border); box-shadow: var(--shadow-card); transition: border-color 0.25s var(--ease), transform 0.25s var(--ease); flex-wrap: wrap; }
.chain-card:hover { border-color: var(--border-strong); transform: translateY(-2px); }
.chain-card.off { opacity: 0.6; }
.chain-path { display: flex; align-items: center; flex: 1; min-width: 320px; }
.cp-node { display: flex; align-items: center; gap: 8px; padding: 7px 11px; border-radius: 10px; background: rgba(255,255,255,0.05); border: 1px solid var(--border); font-size: 12.5px; font-weight: 600; white-space: nowrap; }
.cp-node svg { width: 15px; height: 15px; }
.cp-link { flex: 1; min-width: 26px; height: 2px; margin: 0 6px; background: repeating-linear-gradient(90deg, rgba(var(--accent-rgb), 0.95) 0 6px, transparent 6px 12px); background-size: 24px 2px; animation: linkFlow 0.8s linear infinite; border-radius: 2px; }
.chain-card.off .cp-link { animation: none; background: var(--border-strong); }
@keyframes linkFlow { to { background-position: 24px 0; } }
.chain-meta { display: flex; flex-direction: column; gap: 3px; min-width: 150px; }
.chain-meta .t { font-weight: 600; font-size: 13.5px; }
.chain-meta .s { font-family: var(--mono); font-size: 11px; color: var(--muted2); }
.chain-actions { display: flex; align-items: center; gap: 10px; }
@media (max-width: 1100px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.grid-2 { grid-template-columns: 1fr; }
.cb-inspector-body { grid-template-columns: 1fr; gap: 14px; }
}
@media (max-width: 820px) {
#app { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; flex-direction: row; flex-wrap: wrap; gap: 4px; padding: 10px; }
.sidebar .nav-label, .sidebar-footer .version-tag { display: none; }
.brand { padding: 4px 8px; width: 100%; }
.main { padding: 0 16px 40px; }
.topbar { margin: 0 -16px 18px; padding: 12px 16px; }
.search-pill { min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !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; }
}
.cb-dot { fill: var(--accent); filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.9)); }
td.act { white-space: nowrap; }
.form-row > .btn { flex: 0 0 auto; }
.users-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; align-items: flex-end; }
.users-bar > .grow { flex: 1; min-width: 220px; }
.users-bar input { margin-bottom: 0; }
</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>
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
<div class="splash-title" id="splash-brand-name">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">
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
<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><span id="sidebar-brand-name">MBS Panel</span></div>
<div class="nav-label">Обзор</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-label">Сеть</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="chains" onclick="showView('chains')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>Цепочки<span class="nav-count" id="nav-chains-count"></span></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-label">Продажи</div>
<div class="nav-item" data-view="users" onclick="showView('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>Юзеры</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="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-label">Система</div>
<div class="nav-item" data-view="audit" onclick="showView('audit')"><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"/><polyline points="12,7 12,12 15.5,14"/></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.6.0 · <a href="https://github.com/savsisbtw/mbs-panel/releases/latest" target="_blank" style="color:inherit">обновления</a></div>
</div>
</div>
<div class="main">
<div class="topbar">
<div class="crumbs"><span id="crumb-brand">MBS Panel</span><span class="sep">/</span><b id="crumb-title">Дашборд</b></div>
<div class="topbar-spacer"></div>
<div class="search-pill" onclick="cmdkOpen()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="20" y1="20" x2="16.5" y2="16.5"/></svg><span>Поиск и команды</span><span class="kbd">Ctrl K</span></div>
<div class="accent-dots" id="accent-dots"></div>
<div class="live-pill"><span class="live-dot"></span>онлайн</div>
</div>
<div class="content">
<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="grid-2">
<div class="panel">
<div class="panel-head"><h2>Сеть</h2><button class="muted-btn" onclick="showView('nodes')">Все ноды →</button></div>
<div id="net-list"></div>
</div>
<div class="panel">
<div class="panel-head"><h2>Последние действия</h2><button class="muted-btn" onclick="showView('audit')">Журнал →</button></div>
<div id="audit-preview"></div>
</div>
</div>
<div class="section">
<div class="section-head"><h2>Последние подписки</h2><button class="muted-btn" onclick="showView('subscriptions')">Все подписки →</button></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-users" class="view">
<div class="page-title">Юзеры</div>
<div class="page-sub">Все, кто уже есть в системе, в том числе те, у кого ни разу не было подписки (в «Подписках» их не видно). Открой карточку и выдай подписку на любую ноду и срок.</div>
<div class="users-bar">
<div class="grow"><label class="f">Поиск</label><input type="text" id="users-search" placeholder="Юзернейм или Telegram ID" oninput="usersSearchInput()"></div>
<div style="min-width:220px"><label class="f">Выдать по Telegram ID</label><input type="text" id="users-by-id" inputmode="numeric" placeholder="123456789" onkeydown="if(event.key==='Enter')grantByTelegramId()"></div>
<div><button class="btn" style="padding:11px 18px" onclick="grantByTelegramId()">Выдать по ID</button></div>
</div>
<div class="check-hint" id="users-count" style="margin:-6px 0 12px"></div>
<div class="table-wrap"><table><thead><tr>
<th>Пользователь</th><th>Подписки</th><th>Активна до</th><th>Устройств</th><th>Зарегистрирован</th><th></th>
</tr></thead><tbody id="users-body"></tbody></table></div>
</div>
<div id="view-subscriptions" class="view">
<div class="page-title">Подписки</div>
<div class="page-sub">Все выданные подписки</div>
feat: search and status filter on the Подписки table Another line off the fresh docs.rw comparison from last commit: "User Management Filters — Extended selection (Remnawave) vs Minimal options (Marzban)". The subscriptions table had none at all — no search, no status filter, just the raw list with a server-side limit=200. Fine with a handful of test subscriptions, useless once a real business has a few hundred customers and support needs to find one person's row. Pure client-side: the full list was already fetched in one call (/admin/api/subscriptions), so filtering it in the browser needs no new backend route and can't regress anything server-side. Refactored loadSubscriptions() to keep the fetched list in allSubs and render through a separate renderFilteredSubs(), which the existing revoke/hold/resume refresh calls now go through too — so the search box and status filter stay applied after an action instead of resetting the view. Search matches username, tg_id, node label, and plan label as one lowercased substring check. Status filter (active / on hold / expired- revoked / all) reuses the exact three-way split statusBadge() already draws, via a new subStatus() helper — same custom .dd dropdown as everywhere else in the panel, not a native <select>. Verification: extracted the actual subStatus()/renderFilteredSubs() filter predicate out of admin.html — not a reimplementation, diffed it against the file to confirm byte-for-byte match — and ran it under Node against four mock subscriptions covering all three statuses, including one with a null username (the real shape for gift-redeemed subs with no Telegram username set) to make sure the search doesn't throw on that. 13 checks: plain search, case-insensitivity, tg_id/node/plan matching, no-match, each status filter alone, and two combined search+status cases. node --check on the full extracted script, div-tag balance on the whole file, both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 02:27:40 +05:00
<div class="form-row" style="margin-bottom:16px">
<div><input type="text" id="subs-search" placeholder="Поиск: юзернейм, tg id, сервер, тариф" oninput="renderFilteredSubs()"></div>
<div style="flex:0;min-width:180px"><div id="subs-status-filter" class="dd"></div></div>
<div style="flex:0;min-width:0"><a class="btn ghost" style="white-space:nowrap;padding:11px 16px" href="/admin/api/subscriptions/export.csv">Экспорт CSV</a></div>
feat: search and status filter on the Подписки table Another line off the fresh docs.rw comparison from last commit: "User Management Filters — Extended selection (Remnawave) vs Minimal options (Marzban)". The subscriptions table had none at all — no search, no status filter, just the raw list with a server-side limit=200. Fine with a handful of test subscriptions, useless once a real business has a few hundred customers and support needs to find one person's row. Pure client-side: the full list was already fetched in one call (/admin/api/subscriptions), so filtering it in the browser needs no new backend route and can't regress anything server-side. Refactored loadSubscriptions() to keep the fetched list in allSubs and render through a separate renderFilteredSubs(), which the existing revoke/hold/resume refresh calls now go through too — so the search box and status filter stay applied after an action instead of resetting the view. Search matches username, tg_id, node label, and plan label as one lowercased substring check. Status filter (active / on hold / expired- revoked / all) reuses the exact three-way split statusBadge() already draws, via a new subStatus() helper — same custom .dd dropdown as everywhere else in the panel, not a native <select>. Verification: extracted the actual subStatus()/renderFilteredSubs() filter predicate out of admin.html — not a reimplementation, diffed it against the file to confirm byte-for-byte match — and ran it under Node against four mock subscriptions covering all three statuses, including one with a null username (the real shape for gift-redeemed subs with no Telegram username set) to make sure the search doesn't throw on that. 13 checks: plain search, case-insensitivity, tg_id/node/plan matching, no-match, each status filter alone, and two combined search+status cases. node --check on the full extracted script, div-tag balance on the whole file, both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 02:27:40 +05:00
</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>Пинг</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="view-chains" class="view">
<div class="page-title">Цепочки</div>
<div class="page-sub">Собери маршрут <b>клиент → сервер → сервер → интернет</b>. Зажми левую кнопку мыши на «Клиенте» и протяни провод через серверы из пула к «Интернету». Один сервер — это обычное подключение, два — цепочка. Больше двух нельзя: каждый прыжок добавляет задержку.</div>
<div class="cb-layout">
<div class="cb-scroll">
<div class="cb-canvas" id="cb-canvas">
<svg class="cb-wires" id="cb-wires" xmlns="http://www.w3.org/2000/svg"></svg>
<div class="cb-node cb-endpoint cb-client" id="cb-client">
<span class="cb-port out"></span>
<div class="ico"><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="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg></div>
<div class="nm">Клиент</div>
<div class="ad" style="max-width:none">зажми ЛКМ и тяни</div>
</div>
<div class="cb-pool-wrap">
<div class="cb-pool-label">Пул серверов</div>
<div class="cb-pool" id="cb-pool"></div>
</div>
<div class="cb-node cb-endpoint cb-internet" id="cb-internet">
<span class="cb-port in"></span>
<div class="ico"><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="M3 12h18M12 3c3 3.2 3 14.8 0 18M12 3c-3 3.2-3 14.8 0 18"/></svg></div>
<div class="nm">Интернет</div>
</div>
</div>
</div>
<div class="cb-hint" id="cb-hint">зажми <b>ЛКМ</b> на клиенте, веди провод через серверы, отпусти на интернете · клик по серверу тоже добавляет его · <b>Esc</b> — сбросить</div>
<div class="cb-inspector panel">
<div class="panel-head"><h2>Маршрут</h2><button class="muted-btn" onclick="cbReset()">Сбросить</button></div>
<div class="cb-inspector-body">
<div class="cb-steps" id="cb-steps"></div>
<div id="cb-callouts"></div>
<div>
<label class="f">Название</label>
<input type="text" id="cb-label" placeholder="Название (необязательно)" style="margin-bottom:12px">
<button class="btn block" style="margin-top:0" id="cb-create" onclick="cbCreate()" disabled>Создать цепочку</button>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-head"><h2>Мои цепочки</h2></div>
<div id="chains-list"></div>
</div>
</div>
<div id="view-audit" class="view">
<div class="page-title">Журнал</div>
<div class="page-sub">Кто и что менял в панели — последние 300 действий. Пароли и ключи сюда не попадают, только факт действия.</div>
<div class="form-row" style="margin-bottom:16px">
<div style="flex:0;min-width:220px"><div id="audit-filter" class="dd"></div></div>
<div style="flex:0"><button class="btn ghost" style="padding:11px 16px" onclick="loadAudit()">Обновить</button></div>
</div>
<div class="table-wrap"><table><thead><tr>
<th>Время</th><th>Админ</th><th>Действие</th><th>Детали</th><th>IP</th>
</tr></thead><tbody id="audit-body"></tbody></table></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>
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
<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>
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
<p class="check-hint">Панель сама постучится в ЮKassa (<code>/v3/me</code>) и сохранит ключи только если они рабочие. После сохранения сразу включаются приём оплаты и приём вебхуков — без рестарта; бот на всякий случай перезапускается сам, чтобы кнопки оплаты в Telegram тоже обновились немедленно.</p>
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
<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>
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
<p class="check-hint">У Platega нет публичного эндпоинта для проверки ключей без реального платежа, так что сохраняется без предварительной проверки — если ключи неверные, это будет видно по первой неудачной оплате. Применяется сразу, без рестарта.</p>
<div id="platega-result"></div>
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
</div>
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
<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>
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
<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>
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
<div class="doc-block">
<h2>Название и клиентский сайт</h2>
<p>Настройки → «Название» — своё название бренда вместо дефолтного «MBS Panel», показывается сразу везде: заголовок и сайдбар панели, сообщения бота, страница подписки, оферта/политика, otpauth-issuer в приложении-аутентификаторе при включении 2FA. Применяется мгновенно, без рестарта.</p>
<p>На домене подписок (<code>SUB_DOMAIN</code>) панель теперь сама отдаёт готовый клиентский сайт — корень (<code>/</code>) рендерит <code>site/index.html</code> (лендинг с живыми тарифами из <code>/api/plans</code>), <code>/cabinet.html</code> — личный кабинет по токену из бота. Оба шаблона лежат в репо (<code>site/</code>) — правишь HTML/CSS напрямую, если нужен свой дизайн, панель только подставляет название/домены/юзернейм бота при каждом запросе.</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>клиент → сервер A → сервер B → интернет</code>. По умолчанию клиент ходит через одну ноду; цепочка добавляет второй прыжок: трафик заходит на сервер A, оттуда уходит на сервер B и только он выпускает его в интернет. Для сайтов виден IP сервера B, а клиент подключается к A — удобно, когда вход надо держать в «чистом» регионе, а выход — в нужной стране. Больше двух серверов нельзя намеренно: каждый лишний прыжок добавляет задержку.</p>
<p>Как собрать: зажми левую кнопку мыши на «Клиенте» и тяни провод — когда проходишь над сервером из пула, он цепляется в маршрут (цифра 1 — вход, 2 — выход). Отпусти провод на «Интернете», чтобы замкнуть. Можно и кликами: клик по серверу добавляет его, клик по «Интернету» замыкает. Esc или «Сбросить» — начать заново, крестик на сервере убирает его и всё, что после.</p>
<p>Что происходит под капотом: на входной ноде панель добавляет Xray-inbound <code>chain-код</code> (тот же ключ Reality, что у ноды, но отдельный порт из диапазона 10443–10999 и свой short ID) и outbound до выходной ноды, а в routing — правило «всё из этого inbound уходит в этот outbound». На выходной ноде заводится служебный клиент <code>relay-код</code> — под ним входная нода ходит на выход. Порт открывается в ufw сам. Пользователям входной ноды в подписку добавляется ещё одна ссылка «Вход → Выход». Подписчикам выходной ноды цепочка не выдаётся.</p>
<p>Перед применением панель прогоняет <code>xray run -test</code>, проверяет, что порт не занят, и после рестарта убеждается, что Xray поднялся — если нет, конфиг откатывается сам. Любая ошибка цепочки не блокирует синхронизацию подписчиков: клиенты всё равно применятся, а проблема покажется в ответе.</p>
<p>Выходом может быть и внешняя нода с общим (shared) UUID — тогда служебный клиент не нужен. Входом — только локальная или управляемая нода, потому что панель правит её конфиг. Ноду, которая участвует в цепочке, нельзя удалить — сначала удали цепочку.</p>
<p>Важно для Reality: SNI-маскировка (<code>dest</code>) не должна быть сайтом с пост-квантовым обменом ключами (например, www.microsoft.com) — на таком Reality не работает ни в цепочке, ни без неё. Дефолтный <code>www.wildberries.ru</code> подходит.</p>
</div>
<div class="doc-block">
<h2>Юзеры и выдача подписок</h2>
<p>Страница «Юзеры» — все, кто уже есть в базе, включая тех, у кого подписок не было ни разу (в «Подписках» они не появляются, потому что там только выданное). Поиск идёт по юзернейму и Telegram ID. «Выдать подписку» открывает карточку юзера: выбираешь ноду и срок, подписка создаётся сразу, а клиент добавляется в Xray. Поле «Выдать по Telegram ID» работает и для тех, кого в базе ещё нет: запись создастся при выдаче, подписка будет ждать, пока человек зайдёт в бота. Каждая выдача попадает в журнал.</p>
</div>
<div class="doc-block">
<h2>Журнал действий</h2>
<p>Страница «Журнал» — кто из админов и когда менял ноды, цепочки, подписки, настройки, скачивал бэкап и входил в панель (включая неудачные входы и неверные коды 2FA, с IP). Хранится последние 5000 записей. Содержимое запросов (пароли, ключи, токены) в журнал не пишется — только факт и адрес действия.</p>
</div>
<div class="doc-block">
<h2>Поиск и команды</h2>
<p><code>Ctrl K</code> (или <code>⌘ K</code>) открывает палитру: быстрый переход на любую страницу, к любой ноде или цепочке, экспорт подписок в CSV, бэкап, смена акцентного цвета панели. Выбранный акцент хранится в браузере.</p>
</div>
<div class="doc-block">
<h2>Пароль и безопасность</h2>
<p>Пароль админ-панели меняется командой <code>mbs pass</code> на сервере (без аргумента — сгенерит случайный). Панель физически откажется стартовать, если в <code>.env</code> стоит "change-me"/"admin"/что-то короче 8 символов — так что пропустить это не выйдет по-тихому.</p>
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
<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>
feat: optional custom admin login path — matches a Remnawave-listed security measure Marzban doesn't have Pulled a fresh copy of docs.rw's own Remnawave-vs-Marzban comparison table (not working from memory of an earlier read) to check what's still genuinely different after tonight's run of fixes — most rows already match or beat both panels (multi-admin, 2FA, HWID limits, backup/restore, host sorting, config validation, node autonomy, on-hold status as of a few commits ago). One concrete, bounded, unclaimed row: "Security measures in documentation" lists CF zero trust / custom path / Telegram OAuth / 2FA for Remnawave, nothing for Marzban. We already had 2FA and rate-limiting; custom path was the missing, actually implementable piece — everything else in that row is deployment guidance, not panel code. New ADMIN_PATH env var (config.py, defaults to "admin" — every existing install keeps working exactly as before with zero action needed). The page-serving route moves to whatever path is configured; root() on PANEL_DOMAIN only falls through to serving admin.html when ADMIN_PATH is still the default, otherwise it shows the same branded landing page every other domain gets — so a scanner or a human guessing "/admin" finds nothing once this is set, not even a redirect that confirms something lives there. Deliberately scoped to ONLY the page route. /admin/api/* stays fixed — it's already behind real cookie+session auth (verified this while auditing: every mutating admin route either calls require_admin() or the equivalent _require_current_admin(), checked programmatically via ast rather than trusting my memory of having added the check everywhere — found nothing actually missing, which is itself worth knowing, not just assumed). Moving the API namespace too would be a much bigger, riskier rewrite of every @app decorator in the file for no real security gain over what auth already provides. Deliberately NOT exposed in the Settings UI, unlike almost everything else made live-editable tonight. This one genuinely needs a process restart to take effect (FastAPI resolves routes at import time, not per-request), and a typo saved through the UI followed by a restart is a real self-lockout risk with no web-based way back — same tier as PANEL_DOMAIN/SUB_DOMAIN, which are also .env-only for the same reason. .env + SSH is the correct blast radius for a setting that can lock you out. Verification: config.py's normalization (strip slashes, empty/lone- slash/repeated-slash input all falling back to "admin" rather than accidentally producing a route at bare "/") tested directly — 8 cases. AST-extracted the updated root() out of api.py (still can't import the module locally) and exercised its actual branching with a mocked FileResponse/legal/request — confirmed the default case is byte-for- byte the old behavior and the custom-path case stops serving admin.html on PANEL_DOMAIN's root. Added a dedicated CI step that does what only a real FastAPI import can prove: with ADMIN_PATH set, /xyz123secret is a registered route, plain /admin is NOT (not just supplemented — actually gone), and /admin/api/login is untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:58:45 +05:00
<p>Страницу входа можно увести с дефолтного <code>/admin</code> на свой путь — переменная <code>ADMIN_PATH</code> в <code>.env</code> на сервере (не через UI — это единственная настройка, которая намеренно не в панели, чтобы нельзя было опечататься и остаться без доступа без SSH). Требует <code>mbs restart</code>. Это доп. слой поверх rate-limit и 2FA, не замена — сам <code>/admin/api/*</code> не двигается, он и так защищён логином.</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>
feat: outbound webhooks for node lifecycle — closes the "users + nodes" gap from the comparison Last remaining actionable row from the docs.rw comparison table pulled two commits ago: "Webhook event support — Users + nodes (Remnawave) / Users only (Marzban)". Every webhook we send is subscription/payment events — user-side only, same as Marzban, even after last commit's revoke/hold/resume additions. Zero node events. node.added on creation, node.deleted on deletion (captures the node's label before it's gone, since delete_node doesn't return the row), node.enabled/node.disabled on the PATCH route — but only when the enabled field actually changes value, not on every save. Editing just the label, or PATCHing enabled to the same value it already had, correctly fires nothing — checked this specifically since a naive "enabled is in the request body" check would have spammed an event on every harmless edit of an already-enabled node. Verification: same two-part approach as the subscription lifecycle webhooks. AST-extracted the actual admin_update_node() body out of api.py (still can't import it directly) and ran it against a fake db/webhooks module — 5 cases: enabling, disabling, a same-value no-op save, and an unrelated-field-only edit, confirming the webhook fires exactly when and only when it should. Then a real local HTTP server for all four event types through the actual webhooks.send(), receiver-side HMAC recomputed independently from its own copy of the secret and compared against X-Signature, not trusted from the sender. README's feature list had also fallen behind the last three commits (webhooks, hold/pause, subscription search never got a bullet) — caught up all three while I was in there, not just the one this commit adds. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 02:56:25 +05:00
<p>Исходящие вебхуки (Настройки → Webhook на события) — панель стучится на указанный URL при оплате (<code>payment.paid</code>), ручной выдаче подписки админом (<code>subscription.granted_by_admin</code>), отзыве (<code>subscription.revoked</code>), постановке на паузу (<code>subscription.held</code>) и возобновлении (<code>subscription.resumed</code>), а также при добавлении (<code>node.added</code>), удалении (<code>node.deleted</code>) и включении/выключении ноды (<code>node.enabled</code>/<code>node.disabled</code> — только когда состояние реально поменялось, не на каждое сохранение формы редактирования). Тело подписано <code>X-Signature</code> (HMAC-SHA256). Секрет выдаётся один раз и не меняется при правке URL — для интеграций со своими системами, без опроса API.</p>
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
<p>Вкладка Платежи → «Тарифы» — цены по срокам и общий рубильник приёма оплаты. Как и реквизиты с ключами провайдеров, это читается панелью напрямую из <code>.env</code> при каждом запросе — правки в UI применяются мгновенно везде (бот, API, проверка вебхуков), рестарт панели нигде не требуется.</p>
</div>
<div class="doc-block">
<h2>Лимит устройств (HWID)</h2>
<p>Настройки → «Лимит устройств» — глобальный рубильник и лимит по умолчанию (как у Remnawave: клиент шлёт заголовок <code>x-hwid</code> при запросе конфига, панель запоминает первые N уникальных устройств на юзера и отказывает новым сверх лимита). У конкретного юзера лимит можно переопределить отдельно — в его карточке (Подписки → кнопка «Карточка» → таб «Устройства»), это имеет приоритет над глобальным значением по умолчанию.</p>
</div>
feat: pause/resume a subscription without losing paid time (Remnawave-style hold) From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
<div class="doc-block">
<h2>Пауза подписки</h2>
<p>Кнопка «Пауза» у активной подписки (в Подписках и в карточке юзера) — не то же самое, что «Отозвать». Пауза сразу убирает клиента из Xray (доступ пропадает), но остаток срока сохраняется: сколько дней было на паузе — ровно столько добавится к <code>expires_at</code> при нажатии «Возобновить». «Отозвать», наоборот, необратимо — новую подписку тогда выдаёт только «Карточка» → ручная выдача.</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">
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
<div class="section-head"><h2>Название</h2></div>
<p class="page-sub" style="margin-bottom:16px">Показывается везде, где сейчас видят клиенты и ты сам: сайт, бот, страница подписки, оферта/политика, вход в панель.</p>
<div class="form-row">
<div><label class="f">Название бренда</label><input type="text" id="brand-name-input" placeholder="MBS Panel"></div>
<div style="flex:0"><label class="f">&nbsp;</label><button class="btn" onclick="saveBrandName()">Сохранить</button></div>
</div>
<p class="check-hint">Применяется сразу везде, без рестарта.</p>
<div id="brand-name-result"></div>
</div>
<div class="section" style="margin-top:20px">
<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:var(--mono)">—</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>
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
<p class="check-hint">Панель сама проверит токен у Telegram (запрос getMe) перед применением и подставит настоящий юзернейм бота — придумывать не нужно. Применяется сразу — бот перезапускается сам, а панель (уведомления об оплате, ссылки на бота) подхватывает новый токен и юзернейм без рестарта.</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>
feat: outbound webhooks for payment/subscription events Per the docs.rw comparison researched earlier tonight, Remnawave fires webhooks for users+nodes and Marzban for users — this panel had neither, only received inbound webhooks from payment providers. New webhooks.py, fired on payment.paid (both webhook-driven and reconciler-driven grant paths, so it fires regardless of which one actually processes a given payment) and subscription.granted_by_admin (kept as a distinct event name rather than reusing payment.paid, since no money necessarily changed hands there). Settings tab gets a URL field; a secret is generated once on first save via secrets.token_hex and never regenerated on later URL edits, so a receiver's signature verification doesn't silently break when the admin just updates the endpoint. Every delivery is HMAC-SHA256 signed over the raw JSON body via X-Signature, same verification shape Platega already uses for its inbound webhooks. Delivery is fire-and-forget (10s timeout, swallows all exceptions) — a receiver being down must never block or fail a payment grant. Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's existing reader instead of adding a third copy of that logic. Verified with a real local HTTP server: actual delivery, payload shape, and that the received X-Signature verifies against the configured secret using the receiver's own side of the HMAC — not just asserting the sender computed *something*. Also verified the no-URL-configured no-op path and that changing the URL later does not rotate the secret. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
<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>, <code>subscription.revoked</code>, <code>subscription.held</code>, <code>subscription.resumed</code>, <code>node.added</code>, <code>node.deleted</code>, <code>node.enabled</code>, <code>node.disabled</code>, <code>chain.created</code>, <code>chain.deleted</code>, <code>chain.enabled</code>, <code>chain.disabled</code>. Тело — JSON <code>{"event": "...", "data": {...}}</code>, подписано заголовком <code>X-Signature</code> (HMAC-SHA256 от тела запроса на секрете ниже) — так получатель проверяет, что запрос реально от панели.
feat: outbound webhooks for payment/subscription events Per the docs.rw comparison researched earlier tonight, Remnawave fires webhooks for users+nodes and Marzban for users — this panel had neither, only received inbound webhooks from payment providers. New webhooks.py, fired on payment.paid (both webhook-driven and reconciler-driven grant paths, so it fires regardless of which one actually processes a given payment) and subscription.granted_by_admin (kept as a distinct event name rather than reusing payment.paid, since no money necessarily changed hands there). Settings tab gets a URL field; a secret is generated once on first save via secrets.token_hex and never regenerated on later URL edits, so a receiver's signature verification doesn't silently break when the admin just updates the endpoint. Every delivery is HMAC-SHA256 signed over the raw JSON body via X-Signature, same verification shape Platega already uses for its inbound webhooks. Delivery is fire-and-forget (10s timeout, swallows all exceptions) — a receiver being down must never block or fail a payment grant. Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's existing reader instead of adding a third copy of that logic. Verified with a real local HTTP server: actual delivery, payload shape, and that the received X-Signature verifies against the configured secret using the receiver's own side of the HMAC — not just asserting the sender computed *something*. Also verified the no-URL-configured no-op path and that changing the URL later does not rotate the secret. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
Секрет для проверки: <code id="webhook-secret-display">—</code>
</p>
<div id="webhook-result"></div>
</div>
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
<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>
</div>
<div id="toast-stack"></div>
<div id="cmdk-overlay"><div class="cmdk"><div class="cmdk-input-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="20" y1="20" x2="16.5" y2="16.5"/></svg><input type="text" id="cmdk-input" placeholder="Куда перейти или что сделать…" autocomplete="off"></div><div class="cmdk-list" id="cmdk-list"></div><div class="cmdk-foot"><span>↑↓ выбрать</span><span>Enter открыть</span><span>Esc закрыть</span></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"); }
fix: admin panel showed raw JSON error envelopes instead of the actual message Found while adding one more input check (webhook URL scheme) and noticing the error would render as literal {"detail":"..."} text in the UI. Root cause is in the shared api() JS helper, not any individual route: on a non-ok response it did `throw new Error(await res.text())` — the raw response body, not the parsed message. FastAPI's default HTTPException handler returns {"detail": "message"} as JSON, so every `esc(e.message)` display in the panel (roughly 15 call sites) was showing the whole JSON envelope, curly braces and quotes included, not just the message inside it. Confirmed this wasn't already handled by checking login()'s own catch block — it hardcodes a fixed string instead of showing e.message at all, which only makes sense if e.message was never fit to show directly. This affects every validation message added the last several commits (prices, HWID settings, node creation, provision-guide, hwid-limit) and plenty from before tonight too — not something introduced by this session, but something this session's run of new validation made worth actually fixing rather than shipping another error message into a broken display path. api(): on error, try to JSON.parse the body and use .detail if it's a string; anything that doesn't match that exact shape (plain text body, malformed JSON, FastAPI's array-shaped 422 validation-error detail) falls through to the original raw-text behavior unchanged, so nothing that worked before regresses. Also added the actual check that prompted this: webhook URL must start with http:// or https://, rejecting things like a bare hostname or a file:// URL (webhooks.send() never reads or forwards the response body, so this was never a real exfiltration path, but it's an essentially free guard against both a fat-fingered URL that would otherwise silently never deliver anything, and the more deliberate file://-style misuse). Verification: the api() fix is pure client-side logic with no backend dependency, so tested directly under Node against a mocked fetch — 6 cases: the exact FastAPI {detail: string} shape extracting cleanly, a non-JSON error body falling back unchanged, the Pydantic array-detail 422 shape not crashing the parser, malformed JSON falling back to raw text, the 401/showLogin path completely unchanged, and the successful- response happy path unaffected. AST-extracted admin_set_webhook_settings out of api.py (still can't import it directly) and ran it against a fake legal/env module — 6 cases covering both accepted schemes, both rejected ones (ftp://, file://), a scheme-less bare hostname, and confirming clearing the webhook with an empty string still works. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 04:25:34 +05:00
if (!res.ok) {
const text = await res.text();
let message = text;
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed.detail === "string") message = parsed.detail;
} catch (e) {}
throw new Error(message);
}
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");
document.getElementById("crumb-title").textContent = VIEW_TITLES[name] || name;
window.scrollTo({ top: 0 });
if (name === "dashboard") loadDashboard();
if (name === "users") loadUsers();
if (name === "subscriptions") loadSubscriptions();
if (name === "gifts") loadGifts();
if (name === "nodes") loadNodes();
if (name === "chains") loadChains();
if (name === "traffic") loadTraffic();
if (name === "audit") loadAudit();
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
if (name === "payments") { loadPayments(); loadPaymentsSettings(); }
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
if (name === "settings") { loadBrandName(); 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}`,
}));
feat: search and status filter on the Подписки table Another line off the fresh docs.rw comparison from last commit: "User Management Filters — Extended selection (Remnawave) vs Minimal options (Marzban)". The subscriptions table had none at all — no search, no status filter, just the raw list with a server-side limit=200. Fine with a handful of test subscriptions, useless once a real business has a few hundred customers and support needs to find one person's row. Pure client-side: the full list was already fetched in one call (/admin/api/subscriptions), so filtering it in the browser needs no new backend route and can't regress anything server-side. Refactored loadSubscriptions() to keep the fetched list in allSubs and render through a separate renderFilteredSubs(), which the existing revoke/hold/resume refresh calls now go through too — so the search box and status filter stay applied after an action instead of resetting the view. Search matches username, tg_id, node label, and plan label as one lowercased substring check. Status filter (active / on hold / expired- revoked / all) reuses the exact three-way split statusBadge() already draws, via a new subStatus() helper — same custom .dd dropdown as everywhere else in the panel, not a native <select>. Verification: extracted the actual subStatus()/renderFilteredSubs() filter predicate out of admin.html — not a reimplementation, diffed it against the file to confirm byte-for-byte match — and ran it under Node against four mock subscriptions covering all three statuses, including one with a null username (the real shape for gift-redeemed subs with no Telegram username set) to make sure the search doesn't throw on that. 13 checks: plain search, case-insensitivity, tg_id/node/plan matching, no-match, each status filter alone, and two combined search+status cases. node --check on the full extracted script, div-tag balance on the whole file, both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 02:27:40 +05:00
DD.subsStatusFilter = createDropdown("subs-status-filter", {
options: [
{ value: "all", label: "Все статусы" },
{ value: "active", label: "Активные" },
{ value: "held", label: "На паузе" },
{ value: "expired", label: "Истекшие/отозванные" },
],
value: "all", placeholder: "Статус",
onChange: () => renderFilteredSubs(),
});
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.auditFilter = createDropdown("audit-filter", {
options: [
{ value: "all", label: "Все события" },
{ value: "login", label: "Входы" },
{ value: "warn", label: "Подозрительное" },
{ value: "node", label: "Ноды" },
{ value: "chain", label: "Цепочки" },
{ value: "sub", label: "Подписки и юзеры" },
{ value: "settings", label: "Настройки и админы" },
],
value: "all", placeholder: "События",
onChange: () => renderAuditTable(),
});
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
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>',
link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>',
};
function statCard(label, value, color, icon, delay, sub) {
const num = typeof value === "number";
return `<div class="stat-card reveal" style="animation-delay:${delay}s">
<div class="l">${label}</div>
<div class="row"><div class="ico" style="color:${color}">${icon}</div><span class="v"${num ? ` data-count="${value}"` : ""}>${num ? 0 : value}</span></div>
${sub ? `<div class="sub">${sub}</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 (!(await askConfirm("Сбросить счётчик трафика для этой подписки?", { ok: "Сбросить" }))) 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();
}
}
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
async function loadPaymentsSettings() {
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
const [legalRes, ykRes, pgRes, planRes] = await Promise.all([
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
api("/admin/api/payments/legal-settings"),
api("/admin/api/payments/yookassa-settings"),
api("/admin/api/payments/platega-settings"),
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
api("/admin/api/payments/plan-settings"),
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
]);
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");
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
if (ykRes.enabled && ykRes.has_secret) {
ykStatus.innerHTML = '<span class="badge ok">подключена</span> shop_id: ' + esc(ykRes.shop_id);
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
} 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>';
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
}
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
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("");
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
}
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 {
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
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>';
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
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 {
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
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>';
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
}
}
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>';
}
}
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
async function loadBrandName() {
const res = await fetch("/api/branding").then((r) => r.json());
document.getElementById("brand-name-input").value = res.brand_name || "";
document.getElementById("brand-name-result").innerHTML = "";
}
async function saveBrandName() {
const brand_name = document.getElementById("brand-name-input").value.trim();
const result = document.getElementById("brand-name-result");
if (!brand_name) return;
try {
const res = await api("/admin/api/branding", { method: "POST", body: JSON.stringify({ brand_name }) });
applyBrandName(res.brand_name);
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено — применилось сразу</p>';
} 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 (!(await askConfirm("Удалить этого админа?", { ok: "Удалить" }))) 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() {
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
let res;
try {
res = await api("/admin/api/2fa/setup", { method: "POST" });
} catch (e) {
toast("Не получилось: " + e.message, "error");
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
return;
}
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>';
}
}
feat: outbound webhooks for payment/subscription events Per the docs.rw comparison researched earlier tonight, Remnawave fires webhooks for users+nodes and Marzban for users — this panel had neither, only received inbound webhooks from payment providers. New webhooks.py, fired on payment.paid (both webhook-driven and reconciler-driven grant paths, so it fires regardless of which one actually processes a given payment) and subscription.granted_by_admin (kept as a distinct event name rather than reusing payment.paid, since no money necessarily changed hands there). Settings tab gets a URL field; a secret is generated once on first save via secrets.token_hex and never regenerated on later URL edits, so a receiver's signature verification doesn't silently break when the admin just updates the endpoint. Every delivery is HMAC-SHA256 signed over the raw JSON body via X-Signature, same verification shape Platega already uses for its inbound webhooks. Delivery is fire-and-forget (10s timeout, swallows all exceptions) — a receiver being down must never block or fail a payment grant. Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's existing reader instead of adding a third copy of that logic. Verified with a real local HTTP server: actual delivery, payload shape, and that the received X-Signature verifies against the configured secret using the receiver's own side of the HMAC — not just asserting the sender computed *something*. Also verified the no-URL-configured no-op path and that changing the URL later does not rotate the secret. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
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 (!(await askConfirm("Заменить текущую базу файлом " + file.name + "? Текущая база сохранится в файл .before-restore-... на сервере, но действие лучше не отменять просто так.", { title: "Восстановление бэкапа", ok: "Восстановить" }))) 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) : "—"; }
feat: pause/resume a subscription without losing paid time (Remnawave-style hold) From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
function statusBadge(active, daysLeft, held) {
if (held) return '<span class="badge warn">на паузе</span>';
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 + " выданных"],
["Нод включено", stats.nodes, "var(--accent)", ICONS.box, "локаций в подписках"],
["Цепочек", stats.chains, "var(--yellow)", ICONS.link, "клиент → A → B → интернет"],
].map(([l, v, c, ic, sub], i) => statCard(l, v, c, ic, i * 0.05, sub)).join("");
runCountUps(grid);
const navCount = document.getElementById("nav-chains-count");
if (navCount) navCount.textContent = stats.chains ? String(stats.chains) : "";
loadNetworkPanel();
loadAuditPreview();
fix: 18-point audit pass — payment races, hwid limit bugs, blocking SSH/HTTP in event loops, N+1 queries, ssh host-key pinning, dead code payments: _grant_paid_subscription now validates plan/node exist before marking a payment paid instead of after (was leaving charged-but-ungranted payments with no error trail); mark_payment_paid is now a single atomic UPDATE ... WHERE status='pending' instead of check-then-act, closing a double-grant race between webhooks and the periodic reconciler; yookassa webhook now re-verifies payment status server-side via the API instead of trusting the posted body (platega already had HMAC verification). hwid: 'user["hwid_limit"] or FALLBACK' treated an explicit 0 (admin fully blocking a user) as unset — now an explicit None check. Device count-check and insert are now one atomic transaction (db.add_device_if_under_limit) instead of two raceable statements. perf: payment webhooks and _grant_paid_subscription's SSH/HTTP calls now run via asyncio.to_thread instead of blocking the event loop; same for bot.py's periodic_sync/reconcile_pending_payments and the manual admin sync button. Admin endpoints (traffic/subscriptions/payments/gift-codes/ user-card) now resolve node labels from one db.list_nodes() call instead of a fresh db.get_node() per row. revoke/reset-traffic use a direct PK lookup instead of scanning up to 5000 rows. Dashboard now asks the API for 8 rows instead of fetching 200 and slicing client-side. security: mbs.db (and -wal/-shm) now chmod 600 right after creation — it held session tokens and subscription bearer tokens world-readable by default. Node SSH connections now pin host keys via a persisted known_hosts file (TOFU) instead of accepting any key on every connection. delete_node now refuses to delete a node with active subscriptions instead of silently orphaning their xray clients. deadcode: removed unused xray_manager.list_client_ids and admin.html's superseded staggerReveal (rows animate via rowAttr() inline now). Also guards gift-code redemption against a plan/node deleted after the code was created (was an unhandled KeyError/TypeError crash). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 22:20:54 +05:00
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>
feat: pause/resume a subscription without losing paid time (Remnawave-style hold) From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
<td>${fmtDate(s.expires_at)}</td><td>${statusBadge(s.active, s.days_left, s.held_at)}</td></tr>
`).join("") : '<tr><td colspan="5"><div class="empty">Пока нет подписок</div></td></tr>';
}
feat: search and status filter on the Подписки table Another line off the fresh docs.rw comparison from last commit: "User Management Filters — Extended selection (Remnawave) vs Minimal options (Marzban)". The subscriptions table had none at all — no search, no status filter, just the raw list with a server-side limit=200. Fine with a handful of test subscriptions, useless once a real business has a few hundred customers and support needs to find one person's row. Pure client-side: the full list was already fetched in one call (/admin/api/subscriptions), so filtering it in the browser needs no new backend route and can't regress anything server-side. Refactored loadSubscriptions() to keep the fetched list in allSubs and render through a separate renderFilteredSubs(), which the existing revoke/hold/resume refresh calls now go through too — so the search box and status filter stay applied after an action instead of resetting the view. Search matches username, tg_id, node label, and plan label as one lowercased substring check. Status filter (active / on hold / expired- revoked / all) reuses the exact three-way split statusBadge() already draws, via a new subStatus() helper — same custom .dd dropdown as everywhere else in the panel, not a native <select>. Verification: extracted the actual subStatus()/renderFilteredSubs() filter predicate out of admin.html — not a reimplementation, diffed it against the file to confirm byte-for-byte match — and ran it under Node against four mock subscriptions covering all three statuses, including one with a null username (the real shape for gift-redeemed subs with no Telegram username set) to make sure the search doesn't throw on that. 13 checks: plain search, case-insensitivity, tg_id/node/plan matching, no-match, each status filter alone, and two combined search+status cases. node --check on the full extracted script, div-tag balance on the whole file, both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 02:27:40 +05:00
let allSubs = [];
function renderSubsTable(subs) {
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>
feat: pause/resume a subscription without losing paid time (Remnawave-style hold) From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
<td>${fmtDate(s.created_at)}</td><td>${fmtDate(s.expires_at)}</td><td>${statusBadge(s.active, s.days_left, s.held_at)}</td>
<td>
<button class="muted-btn" onclick="openUserCard(${s.tg_id}, '${esc(s.username ? '@' + s.username : 'tg' + s.tg_id)}')">Карточка</button>
feat: pause/resume a subscription without losing paid time (Remnawave-style hold) From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
${s.active ? (s.held_at
? `<button class="muted-btn" onclick="resumeSub('${s.uuid}')">Возобновить</button>`
: `<button class="muted-btn" onclick="holdSub('${s.uuid}')">Пауза</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>';
}
feat: search and status filter on the Подписки table Another line off the fresh docs.rw comparison from last commit: "User Management Filters — Extended selection (Remnawave) vs Minimal options (Marzban)". The subscriptions table had none at all — no search, no status filter, just the raw list with a server-side limit=200. Fine with a handful of test subscriptions, useless once a real business has a few hundred customers and support needs to find one person's row. Pure client-side: the full list was already fetched in one call (/admin/api/subscriptions), so filtering it in the browser needs no new backend route and can't regress anything server-side. Refactored loadSubscriptions() to keep the fetched list in allSubs and render through a separate renderFilteredSubs(), which the existing revoke/hold/resume refresh calls now go through too — so the search box and status filter stay applied after an action instead of resetting the view. Search matches username, tg_id, node label, and plan label as one lowercased substring check. Status filter (active / on hold / expired- revoked / all) reuses the exact three-way split statusBadge() already draws, via a new subStatus() helper — same custom .dd dropdown as everywhere else in the panel, not a native <select>. Verification: extracted the actual subStatus()/renderFilteredSubs() filter predicate out of admin.html — not a reimplementation, diffed it against the file to confirm byte-for-byte match — and ran it under Node against four mock subscriptions covering all three statuses, including one with a null username (the real shape for gift-redeemed subs with no Telegram username set) to make sure the search doesn't throw on that. 13 checks: plain search, case-insensitivity, tg_id/node/plan matching, no-match, each status filter alone, and two combined search+status cases. node --check on the full extracted script, div-tag balance on the whole file, both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 02:27:40 +05:00
function subStatus(s) {
if (s.held_at) return "held";
if (!s.active) return "expired";
return "active";
}
function renderFilteredSubs() {
const q = document.getElementById("subs-search").value.trim().toLowerCase();
const statusFilter = DD.subsStatusFilter ? DD.subsStatusFilter.getValue() : "all";
const filtered = allSubs.filter((s) => {
if (statusFilter !== "all" && subStatus(s) !== statusFilter) return false;
if (!q) return true;
const haystack = [
s.username || "", String(s.tg_id), s.node_label || "", s.plan_label || "",
].join(" ").toLowerCase();
return haystack.includes(q);
});
renderSubsTable(filtered);
}
async function loadSubscriptions() {
allSubs = await api("/admin/api/subscriptions");
renderFilteredSubs();
}
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);
if (data.exists === false) document.getElementById("uc-subs-list").innerHTML = '<div class="empty">Этого юзера ещё нет в базе — выдача создаст его, и подписка будет ждать, пока он зайдёт в бота.</div>';
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";
}
feat: pause/resume a subscription without losing paid time (Remnawave-style hold) From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
function refreshUserCard() {
openUserCard(devicesTgId, document.getElementById("devices-title").textContent.replace("Карточка: ", ""));
}
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>
feat: pause/resume a subscription without losing paid time (Remnawave-style hold) From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
<div class="page-sub" style="margin:2px 0 0">до ${fmtDate(s.expires_at)} · ${statusBadge(s.active, s.days_left, s.held_at)}</div>
</div>
feat: pause/resume a subscription without losing paid time (Remnawave-style hold) From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
${s.active ? (s.held_at
? `<button class="muted-btn" onclick="resumeSub('${s.uuid}').then(refreshUserCard)">Возобновить</button>`
: `<button class="muted-btn" onclick="holdSub('${s.uuid}').then(refreshUserCard)">Пауза</button>`) : ""}
${s.active ? `<button class="muted-btn" onclick="revokeSub('${s.uuid}').then(refreshUserCard)">Отозвать</button>` : ""}
</div>
`).join("") : '<div class="empty">Пока нет подписок</div>';
}
async function grantSubscription() {
const node = DD.ucGrantNode.getValue();
const plan = DD.ucGrantPlan.getValue();
if (!node || !plan) return;
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
try {
await api(`/admin/api/users/${devicesTgId}/grant`, { method: "POST", body: JSON.stringify({ node, plan }) });
toast("Подписка выдана", "success");
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
} catch (e) {
toast("Не получилось: " + e.message, "error");
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
}
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) {
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
try {
await api(`/admin/api/users/${devicesTgId}/devices/${deviceId}`, { method: "DELETE" });
} catch (e) {
toast("Не получилось: " + e.message, "error");
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
}
const data = await api(`/admin/api/users/${devicesTgId}`);
renderDevices(data.devices);
}
async function saveHwidLimit() {
const val = document.getElementById("devices-limit").value.trim();
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
try {
await api(`/admin/api/users/${devicesTgId}/hwid-limit`, { method: "POST", body: JSON.stringify({ limit: val || null }) });
} catch (e) {
toast("Не получилось: " + e.message, "error");
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
}
}
async function revokeSub(uuid) {
if (!(await askConfirm("Отозвать подписку? Доступ пропадёт сразу, вернуть можно только новой выдачей.", { ok: "Отозвать" }))) return;
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
try {
await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" });
} catch (e) {
toast("Не получилось: " + e.message, "error");
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
}
loadSubscriptions();
}
feat: pause/resume a subscription without losing paid time (Remnawave-style hold) From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
async function holdSub(uuid) {
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
try {
await api(`/admin/api/subscriptions/${uuid}/hold`, { method: "POST" });
} catch (e) {
toast("Не получилось: " + e.message, "error");
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
}
feat: pause/resume a subscription without losing paid time (Remnawave-style hold) From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
loadSubscriptions();
}
async function resumeSub(uuid) {
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
try {
await api(`/admin/api/subscriptions/${uuid}/resume`, { method: "POST" });
} catch (e) {
toast("Не получилось: " + e.message, "error");
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
}
feat: pause/resume a subscription without losing paid time (Remnawave-style hold) From the original night's low-priority backlog item ("user on hold status") — the only lever admin had for cutting a customer's access was Revoke, which is permanent: the subscription's remaining days are just gone, and restoring access means manually granting a brand-new one and eyeballing how many days to give back. No way to say "block this for a few days, then give the exact remaining time back." db.py: new held_at column on subscriptions (same ALTER-TABLE migration pattern as every other column added this week). hold_subscription() sets it, guarded to only fire on a subscription that's currently active, not already held, not expired — returns False instead of silently no-opping so the caller can tell holding didn't happen. resume_subscription() shifts expires_at forward by exactly how long it was held (now - held_at) and clears held_at, so a subscription paused for 3 days comes back with 3 days added, not 3 days lost. The part that actually mattered for correctness: list_active_subscriptions() now also requires held_at IS NULL. This function is what xray_manager's periodic sync (every 90s) uses to decide which clients belong in Xray's config — without this exclusion, holding a subscription would look like it worked for about 90 seconds and then the next sync would silently re-add the client, since the row still has active=1 and a future expires_at. Found this by actually tracing sync_from_db()/sync_all() before writing the hold logic, not after debugging a live failure. api.py: POST .../hold and .../resume routes, mirroring the existing revoke route (fetch the sub, touch the node's xray client immediately rather than waiting for the next periodic sync, same as revoke already does). _days_left() now takes the whole subscription row instead of just expires_at, so it can use held_at as the reference point instead of "now" for a held subscription — otherwise the admin UI would show the days counter silently ticking down while the customer isn't even able to use the service. admin.html: Пауза/Возобновить buttons next to Отозвать in both the main Подписки table and the per-user card, a "на паузе" badge, and a doc-block explaining the hold-vs-revoke distinction. Also fixed a latent race while touching this code: the old inline revoke handler in the user card fired openUserCard() immediately alongside revokeSub() without waiting for it, so the card could refresh before the revoke's own API call had finished; switched to .then() so hold/resume/revoke all correctly wait for the action before refreshing the card. Verification: db.py has no fastapi/aiogram dependency so this was fully testable locally, unlike most of tonight's api.py/bot.py-touching work. 16 checks against a real isolated sqlite db: hold/resume round-trip, the exclude-from-active-list behavior the xray sync depends on, the exact hours-shift math (simulated a 5h hold by rewriting held_at directly, verified the resumed expires_at landed within 6 minutes of the expected shift), and edge cases — double-hold, double-resume, holding an expired or already-revoked subscription, nonexistent uuid. AST-extracted the updated _days_left() out of api.py (still can't import the module directly) and ran it against hand-built held/active subscription dicts. Added the same hold/resume sequence to the existing CI "TOTP/backup/reorder" step and ran that step's exact full script locally end to end before committing — all six of its sections pass together, not just the new one in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:28:20 +05:00
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();
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
const result = document.getElementById("gift-result");
try {
const res = await api("/admin/api/gift-codes", { method: "POST", body: JSON.stringify({ node, plan }) });
result.innerHTML = `<div class="code-box" style="margin-top:12px">${res.link}<button class="copy-btn" onclick="copyText('${res.link}')">Копировать</button></div>`;
loadGifts();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:12px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
function copyText(t) {
navigator.clipboard.writeText(t).then(() => toast("Скопировано", "success", 1800)).catch(() => toast("Не удалось скопировать", "error"));
}
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><span class="mono" style="font-size:12px">${esc(n.address) || "—"}${n.port && n.address ? ":" + n.port : ""}</span></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="ping-${esc(n.code)}">${n.status === "pending" ? "—" : '<span class="skeleton" style="display:inline-block;width:56px;height:20px"></span>'}</td>
<td id="metrics-${esc(n.code)}">${n.status === "pending" ? "—" : `<button class="muted-btn" onclick="loadNodeMetrics('${esc(n.code)}')">Проверить</button>`}</td>
<td class="act">
<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 red" onclick="deleteNode('${n.code}')">Удалить</button>` : ""}
</td>
</tr>
`).join("");
wireNodeDragAndDrop();
fillNodePing();
}
function fillNodePing() {
fetchLatency().then((lat) => {
nodesCache.forEach((n) => {
const cell = document.getElementById("ping-" + n.code);
if (!cell || n.status === "pending") return;
cell.innerHTML = n.enabled ? msPill(lat[n.code]) : '<span class="check-hint">—</span>';
});
}).catch(() => {});
}
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:var(--mono);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) {
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
try {
await api(`/admin/api/nodes/${code}`, { method: "PATCH", body: JSON.stringify({ enabled }) });
} catch (e) {
toast("Не получилось: " + e.message, "error");
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
}
loadNodes();
}
async function deleteNode(code) {
if (!(await askConfirm("Удалить ноду? Это нельзя отменить.", { ok: "Удалить" }))) return;
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
try {
await api(`/admin/api/nodes/${code}`, { method: "DELETE" });
} catch (e) {
toast("Не получилось: " + e.message, "error");
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
}
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;
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
const guideResult = document.getElementById("guide-result");
let res;
try {
res = await api("/admin/api/nodes/provision-guide", { method: "POST", body: JSON.stringify({ label, address, port, sni, include_ws, include_hysteria2, hysteria_port }) });
} catch (e) {
guideResult.innerHTML = '<p class="page-sub" style="margin-top:12px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
return;
}
guideResult.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);
fix: node-provisioning status poll could run forever, silently or stuck on "waiting" Follow-on from the last commit's error-handling sweep — one more spot that calls api() without a try/catch, but a different shape of problem than the others: this one's a setInterval, not a one-shot action, so a thrown/rejected promise inside it doesn't stop anything — the interval just keeps firing every 4s regardless, forever, with each failure only visible as an unhandled rejection in devtools. And even on the success path there was no upper bound at all: if the node never actually comes online (the admin closes the terminal before finishing the install command, say), "Ожидаю установки…" just sits there indefinitely with no way to know if it's still trying or has effectively given up. Now: a consecutive-error counter that gives up after 5 straight failures with a visible message pointing at the manual "Проверить" button, and an overall 150-attempt cap (10 minutes at the existing 4s interval) that stops polling and says so if the node genuinely never reports active. A single transient failure doesn't trip either — the error counter resets on any successful check, so one blip in an otherwise-working poll doesn't cut it short. Verification: extracted the poll callback's logic (can't spin up a real setInterval usefully in a one-shot Node script) and drove it by calling it directly in sequence, which is what setInterval does under the hood anyway. 6 cases: quick success, a transient error that self-heals by the next tick, 5 consecutive failures giving up with the right message at exactly attempt 5, the 150-attempt timeout firing when status never goes active, and confirming no further attempts happen at all once either give-up path triggers — not just that the message stops updating. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:09:36 +05:00
let pollAttempts = 0, pollErrors = 0;
pollTimer = setInterval(async () => {
fix: node-provisioning status poll could run forever, silently or stuck on "waiting" Follow-on from the last commit's error-handling sweep — one more spot that calls api() without a try/catch, but a different shape of problem than the others: this one's a setInterval, not a one-shot action, so a thrown/rejected promise inside it doesn't stop anything — the interval just keeps firing every 4s regardless, forever, with each failure only visible as an unhandled rejection in devtools. And even on the success path there was no upper bound at all: if the node never actually comes online (the admin closes the terminal before finishing the install command, say), "Ожидаю установки…" just sits there indefinitely with no way to know if it's still trying or has effectively given up. Now: a consecutive-error counter that gives up after 5 straight failures with a visible message pointing at the manual "Проверить" button, and an overall 150-attempt cap (10 minutes at the existing 4s interval) that stops polling and says so if the node genuinely never reports active. A single transient failure doesn't trip either — the error counter resets on any successful check, so one blip in an otherwise-working poll doesn't cut it short. Verification: extracted the poll callback's logic (can't spin up a real setInterval usefully in a one-shot Node script) and drove it by calling it directly in sequence, which is what setInterval does under the hood anyway. 6 cases: quick success, a transient error that self-heals by the next tick, 5 consecutive failures giving up with the right message at exactly attempt 5, the 150-attempt timeout firing when status never goes active, and confirming no further attempts happen at all once either give-up path triggers — not just that the message stops updating. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:09:36 +05:00
pollAttempts++;
let st;
try {
st = await api(`/admin/api/nodes/${res.code}/status`);
pollErrors = 0;
} catch (e) {
pollErrors++;
if (pollErrors >= 5) {
clearInterval(pollTimer);
document.getElementById("guide-status").innerHTML = '<span class="badge bad">Не получилось проверить статус (' + esc(e.message) + ') — проверь вручную кнопкой «Проверить» у ноды</span>';
}
return;
}
if (st.status === "active") {
clearInterval(pollTimer);
document.getElementById("guide-status").innerHTML = '<span class="badge ok">Установлено и подключено</span>';
loadNodes();
fix: node-provisioning status poll could run forever, silently or stuck on "waiting" Follow-on from the last commit's error-handling sweep — one more spot that calls api() without a try/catch, but a different shape of problem than the others: this one's a setInterval, not a one-shot action, so a thrown/rejected promise inside it doesn't stop anything — the interval just keeps firing every 4s regardless, forever, with each failure only visible as an unhandled rejection in devtools. And even on the success path there was no upper bound at all: if the node never actually comes online (the admin closes the terminal before finishing the install command, say), "Ожидаю установки…" just sits there indefinitely with no way to know if it's still trying or has effectively given up. Now: a consecutive-error counter that gives up after 5 straight failures with a visible message pointing at the manual "Проверить" button, and an overall 150-attempt cap (10 minutes at the existing 4s interval) that stops polling and says so if the node genuinely never reports active. A single transient failure doesn't trip either — the error counter resets on any successful check, so one blip in an otherwise-working poll doesn't cut it short. Verification: extracted the poll callback's logic (can't spin up a real setInterval usefully in a one-shot Node script) and drove it by calling it directly in sequence, which is what setInterval does under the hood anyway. 6 cases: quick success, a transient error that self-heals by the next tick, 5 consecutive failures giving up with the right message at exactly attempt 5, the 150-attempt timeout firing when status never goes active, and confirming no further attempts happen at all once either give-up path triggers — not just that the message stops updating. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:09:36 +05:00
} else if (pollAttempts >= 150) {
clearInterval(pollTimer);
document.getElementById("guide-status").innerHTML = '<span class="badge warn">Не дождались за 10 минут — нода появится в списке сама, когда установка на сервере закончится</span>';
}
}, 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",
};
fix: 12 admin actions failed completely silently on error — no message, no visible change, nothing Found by systematically walking every async function in admin.html and checking whether it wraps its api() call in try/catch — 24 didn't. Two of them (createManualNode, generateGuide) are the exact forms whose backend validation this session added over the last several commits: type a duplicate node code, a bad port, anything the new checks reject, and the button just... does nothing. No error, no success message, the click looks like it didn't register. The backend was correctly rejecting bad input with a clear message (and, since two commits ago, that message even displays cleanly instead of as raw JSON) — none of it reached the screen because the calling function never caught the exception to display it. Triaged the other 22 by actual risk instead of fixing all of them: - 12 mutating actions where a silent failure leaves the admin unsure whether their click did anything — grant/revoke/hold/resume a subscription, delete a device, set an HWID limit, create/toggle/ delete a node, create a gift code, start 2FA setup, plus the two above. Fixed all 12. - The remaining ~14 are view-population loads (loadNodes, loadGifts, loadDashboard, etc.) and logout. Deferred, deliberately: their most likely real failure mode is an expired session, which api()'s own 401 handling already resolves by redirecting to the login screen before the exception even reaches the caller — the confusing "did it work" ambiguity that motivates this fix doesn't really apply to a read-only load the way it does to a deliberate action. Two feedback shapes depending on what's nearby: functions with an existing dedicated result <div> (createManualNode, generateGuide, createGift) route the error there, matching how every other form in the panel already shows its errors. Functions with no natural home for inline text (grant/revoke/hold/resume, node toggle/delete, device delete, HWID limit, 2FA setup) use a plain alert() — these are infrequent, deliberate single-action clicks, not something a blocking dialog would be disruptive for. All of them still run their normal refresh after a failure, not just after success, so the view never goes stale relative to what the backend actually did. Verification: pure client-side JS, no backend involved, so tested directly under Node with a mocked api()/alert()/refresh — representative cases from both feedback shapes: holdSub and toggleNode (alert-based, confirmed the real backend message reaches the alert and the refresh still fires on both success and failure), createManualNode (result-div- based, confirmed the error text renders and loadNodes is correctly NOT called when creation genuinely failed), and startEnableTotp (confirmed the early return after a failed setup call avoids a second, more confusing crash from reading .secret off an undefined response). Re-ran the full function-by-function try/catch audit afterward to confirm exactly the intended 12 were fixed and list what's still deferred, rather than assuming the diff did what I meant. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:35:44 +05:00
const result = document.getElementById("manual-result");
try {
await api("/admin/api/nodes", { method: "POST", body: JSON.stringify(body) });
result.innerHTML = '<p class="page-sub" style="margin-top:12px">Нода добавлена.</p>';
loadNodes();
} catch (e) {
result.innerHTML = '<p class="page-sub" style="margin-top:12px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
}
}
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
function applyBrandName(name) {
if (!name) return;
document.title = name;
const splash = document.getElementById("splash-brand-name");
if (splash) splash.textContent = name;
const sidebar = document.getElementById("sidebar-brand-name");
if (sidebar) sidebar.textContent = name;
const crumb = document.getElementById("crumb-brand");
if (crumb) crumb.textContent = name;
}
const VIEW_TITLES = {
dashboard: "Дашборд", users: "Юзеры", subscriptions: "Подписки", gifts: "Гифт-коды", nodes: "Ноды", chains: "Цепочки",
traffic: "Трафик", payments: "Платежи", audit: "Журнал", docs: "Документация", settings: "Настройки",
};
const TOAST_ICONS = {
success: '<svg class="t-ico" 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>',
error: '<svg class="t-ico" 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="M9 9l6 6M15 9l-6 6"/></svg>',
info: '<svg class="t-ico" 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="M12 11v5M12 8h.01"/></svg>',
warn: '<svg class="t-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l10 18H2L12 3z"/><path d="M12 10v5M12 18h.01"/></svg>',
};
function toast(message, type, ms) {
type = type || "info";
ms = ms || (type === "error" ? 6500 : 3800);
const stack = document.getElementById("toast-stack");
const dupe = Array.from(stack.querySelectorAll(".toast:not(.out)")).some((t) => t.dataset.msg === message);
if (dupe) return;
const el = document.createElement("div");
el.dataset.msg = message;
el.className = "toast " + type;
el.innerHTML = TOAST_ICONS[type] + "<div>" + esc(message) + '</div><div class="t-bar" style="animation-duration:' + ms + 'ms"></div>';
stack.appendChild(el);
const close = () => {
el.classList.add("out");
setTimeout(() => el.remove(), 320);
};
const timer = setTimeout(close, ms);
el.addEventListener("click", () => { clearTimeout(timer); close(); });
}
function askConfirm(message, opts) {
opts = opts || {};
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `<div class="modal-card" style="max-width:420px">
<div class="modal-head"><h2>${esc(opts.title || "Подтверди действие")}</h2></div>
<p class="page-sub" style="margin:0 0 8px">${esc(message)}</p>
<div class="modal-actions">
<button class="btn ghost" data-r="0">Отмена</button>
<button class="btn ${opts.danger === false ? "" : "danger"}" data-r="1">${esc(opts.ok || "Подтвердить")}</button>
</div></div>`;
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add("show"));
const done = (value) => {
overlay.classList.remove("show");
setTimeout(() => overlay.remove(), 280);
document.removeEventListener("keydown", onKey);
resolve(value);
};
const onKey = (e) => {
if (e.key === "Escape") done(false);
if (e.key === "Enter") done(true);
};
document.addEventListener("keydown", onKey);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) { done(false); return; }
const btn = e.target.closest("[data-r]");
if (btn) done(btn.dataset.r === "1");
});
});
}
function animateNumber(el, to, duration) {
const start = performance.now();
duration = duration || 900;
function tick(now) {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 4);
el.textContent = Math.round(to * eased);
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
function runCountUps(root) {
(root || document).querySelectorAll("[data-count]").forEach((el) => {
animateNumber(el, parseInt(el.dataset.count, 10) || 0);
});
}
const ACCENTS = [
{ id: "cyan", name: "Бирюзовый", rgb: "34,211,238", deep: "8,145,178" },
{ id: "violet", name: "Фиолетовый", rgb: "167,139,250", deep: "109,40,217" },
{ id: "emerald", name: "Изумрудный", rgb: "52,211,153", deep: "5,150,105" },
{ id: "amber", name: "Янтарный", rgb: "251,191,36", deep: "217,119,6" },
{ id: "rose", name: "Розовый", rgb: "251,113,133", deep: "190,18,60" },
];
function applyAccent(id) {
const a = ACCENTS.find((x) => x.id === id) || ACCENTS[0];
const root = document.documentElement.style;
root.setProperty("--accent-rgb", a.rgb);
root.setProperty("--accent-deep-rgb", a.deep);
try { localStorage.setItem("mbs-accent", a.id); } catch (e) {}
document.querySelectorAll(".accent-dot").forEach((d) => d.classList.toggle("active", d.dataset.id === a.id));
}
function initAccent() {
const box = document.getElementById("accent-dots");
box.innerHTML = ACCENTS.map((a) => `<span class="accent-dot" data-id="${a.id}" title="${a.name}" style="background:rgb(${a.rgb})"></span>`).join("");
box.addEventListener("click", (e) => {
const dot = e.target.closest(".accent-dot");
if (dot) applyAccent(dot.dataset.id);
});
let saved = "cyan";
try { saved = localStorage.getItem("mbs-accent") || "cyan"; } catch (e) {}
applyAccent(saved);
}
function msClass(ms) {
if (ms === null || ms === undefined) return "bad";
if (ms < 80) return "good";
if (ms < 200) return "mid";
return "bad";
}
function msPill(ms) {
if (ms === null || ms === undefined) return '<span class="pill-ms bad">нет ответа</span>';
return `<span class="pill-ms ${msClass(ms)}">${ms} мс</span>`;
}
let latencyCache = {};
async function fetchLatency() {
const rows = await api("/admin/api/nodes/latency");
latencyCache = {};
rows.forEach((r) => { latencyCache[r.code] = r.ms; });
return latencyCache;
}
function fmtDateTime(iso) {
if (!iso) return "—";
const d = new Date(iso + "Z");
if (isNaN(d.getTime())) return iso;
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function timeAgo(iso) {
const d = new Date(iso + "Z");
const sec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
if (sec < 60) return "только что";
if (sec < 3600) return Math.floor(sec / 60) + " мин назад";
if (sec < 86400) return Math.floor(sec / 3600) + " ч назад";
return Math.floor(sec / 86400) + " дн назад";
}
const AUDIT_META = {
"login.ok": ["Вход в панель", "login"],
"login.failed": ["Неудачный вход", "warn"],
"login.totp_failed": ["Неверный код 2FA", "warn"],
"node.add": ["Нода добавлена", "node"],
"node.provision": ["Нода: команда установки", "node"],
"node.reorder": ["Порядок нод изменён", "node"],
"node.edit": ["Нода изменена", "node"],
"node.delete": ["Нода удалена", "node"],
"chain.create": ["Цепочка создана", "chain"],
"chain.edit": ["Цепочка изменена", "chain"],
"chain.delete": ["Цепочка удалена", "chain"],
"sub.revoke": ["Подписка отозвана", "sub"],
"sub.hold": ["Подписка на паузе", "sub"],
"sub.resume": ["Подписка возобновлена", "sub"],
"sub.reset_traffic": ["Сброс трафика", "sub"],
"user.grant": ["Подписка выдана", "sub"],
"user.hwid_limit": ["Лимит устройств юзера", "sub"],
"user.device_delete": ["Устройство удалено", "sub"],
"gift.create": ["Гифт-код создан", "sub"],
"admin.add": ["Админ добавлен", "settings"],
"admin.delete": ["Админ удалён", "settings"],
"2fa.enable": ["2FA включена", "settings"],
"2fa.disable": ["2FA выключена", "settings"],
"backup.download": ["Бэкап скачан", "settings"],
"backup.restore": ["Бэкап восстановлен", "warn"],
"settings.bot": ["Смена бота", "settings"],
"settings.brand": ["Название бренда", "settings"],
"settings.webhook": ["Webhook", "settings"],
"settings.hwid": ["Лимит устройств", "settings"],
"settings.payments": ["Настройки платежей", "settings"],
};
function auditMeta(action) {
return AUDIT_META[action] || [action, ""];
}
let auditRows = [];
function renderAuditTable() {
const filter = DD.auditFilter ? DD.auditFilter.getValue() : "all";
const rows = auditRows.filter((r) => filter === "all" || auditMeta(r.action)[1] === filter);
const body = document.getElementById("audit-body");
body.innerHTML = rows.length ? rows.map((r, i) => {
const [label, cls] = auditMeta(r.action);
return `<tr ${rowAttr(i)}>
<td title="${esc(r.ts)}"><span class="mono" style="font-size:12px">${fmtDateTime(r.ts)}</span> <span class="check-hint">· ${timeAgo(r.ts)}</span></td>
<td>${r.admin ? esc(r.admin) : '<span class="check-hint">—</span>'}</td>
<td><span class="audit-pill ${cls}">${esc(label)}</span></td>
<td><span class="mono" style="font-size:12px;color:var(--muted)">${esc(r.detail || "")}</span></td>
<td><span class="mono" style="font-size:12px;color:var(--muted2)">${esc(r.ip || "")}</span></td>
</tr>`;
}).join("") : '<tr><td colspan="5"><div class="empty">Пока ничего не происходило</div></td></tr>';
}
async function loadAudit() {
auditRows = await api("/admin/api/audit?limit=300");
renderAuditTable();
}
async function loadAuditPreview() {
const box = document.getElementById("audit-preview");
try {
const rows = await api("/admin/api/audit?limit=6");
box.innerHTML = rows.length ? rows.map((r) => {
const [label, cls] = auditMeta(r.action);
return `<div class="net-row"><span class="audit-pill ${cls}">${esc(label)}</span>
<div class="name" style="font-weight:500;color:var(--text-dim)">${r.admin ? esc(r.admin) : "—"}</div>
<span class="check-hint">${timeAgo(r.ts)}</span></div>`;
}).join("") : '<div class="empty" style="padding:24px 0">Журнал пока пуст</div>';
} catch (e) {
box.innerHTML = "";
}
}
async function loadNetworkPanel() {
const box = document.getElementById("net-list");
box.innerHTML = '<div class="skeleton" style="height:38px;margin-bottom:10px"></div>'.repeat(3);
let nodes;
try {
nodes = await api("/admin/api/nodes");
} catch (e) {
box.innerHTML = "";
return;
}
nodesCache = nodes;
box.innerHTML = nodes.map((n) => `
<div class="net-row">
<span class="dot ${n.enabled && n.status === "active" ? "wait" : "off"}" id="net-dot-${esc(n.code)}"></span>
<div class="name">${esc(n.label)}<div class="addr">${esc(n.address || "—")}</div></div>
<span id="net-ms-${esc(n.code)}">${n.status === "pending" ? '<span class="badge warn plain">ждёт установки</span>' : (n.enabled ? '<span class="skeleton" style="display:inline-block;width:54px;height:20px"></span>' : '<span class="badge bad plain">выключена</span>')}</span>
</div>`).join("") || '<div class="empty">Нод пока нет</div>';
try {
const lat = await fetchLatency();
nodes.forEach((n) => {
if (!n.enabled || n.status !== "active") return;
const dot = document.getElementById("net-dot-" + n.code);
const cell = document.getElementById("net-ms-" + n.code);
if (!dot || !cell) return;
const ms = lat[n.code];
dot.className = "dot " + (ms === null || ms === undefined ? "off" : "on");
cell.innerHTML = msPill(ms);
});
} catch (e) {}
}
let usersTimer = 0;
function userLabel(u) {
return u.username ? "@" + u.username : "tg" + u.tg_id;
}
function renderUsers(rows) {
const body = document.getElementById("users-body");
body.innerHTML = rows.length ? rows.map((u, i) => `
<tr ${rowAttr(i)}>
<td><b>${esc(userLabel(u))}</b>${u.username ? ` <span class="check-hint mono">tg${u.tg_id}</span>` : ""}</td>
<td>${u.subs_active ? `<span class="badge ok">${u.subs_active} активн.</span>` : '<span class="badge plain warn">нет активных</span>'} <span class="check-hint">из ${u.subs_total}</span></td>
<td>${u.active_until ? fmtDate(u.active_until) : '<span class="check-hint">—</span>'}</td>
<td><span class="mono">${u.devices}</span></td>
<td>${fmtDate(u.created_at)}</td>
<td class="act">
<button class="muted-btn" onclick="openUserCard(${u.tg_id}, '${esc(userLabel(u))}')">Карточка</button>
<button class="muted-btn" style="color:var(--accent)" onclick="openUserCard(${u.tg_id}, '${esc(userLabel(u))}')">Выдать подписку</button>
</td>
</tr>`).join("") : '<tr><td colspan="6"><div class="empty">Никого не нашлось. Если знаешь Telegram ID — введи его выше и нажми «Выдать по ID».</div></td></tr>';
}
async function loadUsers() {
const q = document.getElementById("users-search").value.trim();
try {
const rows = await api("/admin/api/users?limit=300" + (q ? "&q=" + encodeURIComponent(q) : ""));
renderUsers(rows);
document.getElementById("users-count").textContent = rows.length ? "показано: " + rows.length : "";
} catch (e) {
toast(e.message, "error");
}
}
function usersSearchInput() {
clearTimeout(usersTimer);
usersTimer = setTimeout(loadUsers, 250);
}
function grantByTelegramId() {
const raw = document.getElementById("users-by-id").value.trim();
if (!/^[0-9]{1,15}$/.test(raw)) {
toast("Telegram ID — это только цифры", "warn");
return;
}
openUserCard(parseInt(raw, 10), "tg" + raw);
}
function cmdkItems() {
const items = [];
const nav = [
["dashboard", "Дашборд"], ["users", "Юзеры"], ["subscriptions", "Подписки"], ["gifts", "Гифт-коды"], ["nodes", "Ноды"],
["chains", "Цепочки серверов"], ["traffic", "Трафик"], ["payments", "Платежи"], ["audit", "Журнал действий"],
["docs", "Документация"], ["settings", "Настройки"],
];
nav.forEach(([id, title]) => items.push({ group: "Страницы", label: title, hint: "перейти", icon: "arrow", run: () => showView(id) }));
items.push({ group: "Действия", label: "Построить цепочку серверов", hint: "конструктор", icon: "link", run: () => showView("chains") });
items.push({ group: "Действия", label: "Выдать подписку юзеру", hint: "юзеры", icon: "plus", run: () => showView("users") });
items.push({ group: "Действия", label: "Добавить ноду", hint: "ноды", icon: "plus", run: () => showView("nodes") });
items.push({ group: "Действия", label: "Экспорт подписок в CSV", hint: "скачать", icon: "down", run: () => { window.location.href = "/admin/api/subscriptions/export.csv"; } });
items.push({ group: "Действия", label: "Скачать бэкап", hint: "скачать", icon: "down", run: () => downloadBackup() });
items.push({ group: "Действия", label: "Выйти", hint: "", icon: "out", run: () => logout() });
ACCENTS.forEach((a) => items.push({ group: "Оформление", label: "Акцент: " + a.name, hint: "тема", icon: "dot", run: () => applyAccent(a.id) }));
(nodesCache || []).forEach((n) => items.push({ group: "Ноды", label: n.label, hint: n.address || "", icon: "server", run: () => showView("nodes") }));
(CB.existing || []).forEach((c) => items.push({ group: "Цепочки", label: c.label, hint: c.port, icon: "link", run: () => showView("chains") }));
return items;
}
const CMDK = { open: false, items: [], filtered: [], sel: 0 };
function cmdkIcon(name) {
const paths = {
arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
down: '<path d="M12 4v12M6 12l6 6 6-6M5 20h14"/>',
out: '<path d="M9 4H5v16h4M16 8l4 4-4 4M20 12H9"/>',
dot: '<circle cx="12" cy="12" r="5"/>',
server: '<rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/>',
};
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths[name] || paths.arrow}</svg>`;
}
function cmdkRender() {
const list = document.getElementById("cmdk-list");
let html = "";
let lastGroup = null;
CMDK.filtered.forEach((it, i) => {
if (it.group !== lastGroup) { html += `<div class="cmdk-group">${esc(it.group)}</div>`; lastGroup = it.group; }
html += `<div class="cmdk-item${i === CMDK.sel ? " sel" : ""}" data-i="${i}">${cmdkIcon(it.icon)}<span>${esc(it.label)}</span><span class="hint">${esc(it.hint || "")}</span></div>`;
});
list.innerHTML = html || '<div class="empty" style="padding:30px 0">Ничего не нашлось</div>';
const sel = list.querySelector(".cmdk-item.sel");
if (sel) sel.scrollIntoView({ block: "nearest" });
}
function cmdkFilter() {
const q = document.getElementById("cmdk-input").value.trim().toLowerCase();
const words = q.split(/\s+/).filter(Boolean);
CMDK.filtered = CMDK.items.filter((it) => {
const hay = (it.label + " " + (it.hint || "") + " " + it.group).toLowerCase();
return words.every((w) => hay.includes(w));
});
CMDK.sel = 0;
cmdkRender();
}
function cmdkOpen() {
if (!document.getElementById("app").classList.contains("show")) return;
CMDK.open = true;
CMDK.items = cmdkItems();
const overlay = document.getElementById("cmdk-overlay");
overlay.classList.add("show");
const input = document.getElementById("cmdk-input");
input.value = "";
cmdkFilter();
input.focus();
}
function cmdkClose() {
CMDK.open = false;
document.getElementById("cmdk-overlay").classList.remove("show");
}
function cmdkRun(i) {
const it = CMDK.filtered[i];
if (!it) return;
cmdkClose();
it.run();
}
function initCmdk() {
const input = document.getElementById("cmdk-input");
input.addEventListener("input", cmdkFilter);
document.getElementById("cmdk-list").addEventListener("click", (e) => {
const item = e.target.closest(".cmdk-item");
if (item) cmdkRun(parseInt(item.dataset.i, 10));
});
document.getElementById("cmdk-overlay").addEventListener("mousedown", (e) => {
if (e.target.id === "cmdk-overlay") cmdkClose();
});
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
if (CMDK.open) cmdkClose(); else cmdkOpen();
return;
}
if (!CMDK.open) return;
if (e.key === "Escape") { cmdkClose(); return; }
if (e.key === "ArrowDown") { e.preventDefault(); CMDK.sel = Math.min(CMDK.filtered.length - 1, CMDK.sel + 1); cmdkRender(); }
if (e.key === "ArrowUp") { e.preventDefault(); CMDK.sel = Math.max(0, CMDK.sel - 1); cmdkRender(); }
if (e.key === "Enter") { e.preventDefault(); cmdkRun(CMDK.sel); }
});
}
const CB = {
ready: false,
nodes: [],
draft: [],
linked: false,
dragging: false,
pointer: { x: 0, y: 0 },
probe: null,
probeToken: 0,
existing: [],
lastReject: null,
suppressClick: false,
raf: 0,
hover: { key: null, target: null, timer: 0 },
};
const CB_MAX = 2;
function cbNode(code) {
return CB.nodes.find((n) => n.code === code);
}
function cbFlagOf(label) {
const code = flagToCode(label);
return code ? flagEmoji(code) : "";
}
function cbPlainName(label) {
const code = flagToCode(label);
if (!code) return label || "";
return Array.from(label).slice(2).join("").trim() || label;
}
function cbEligible(node, position) {
if (!node) return "нода не найдена";
if (!node.enabled) return "нода выключена";
if (node.status !== "active") return "нода ещё не установлена";
if (position === 0 && node.kind === "external") return "внешняя нода не может быть входом — панель ею не управляет";
if (position === 1 && node.kind === "external" && !node.shared_uuid) return "у внешней ноды нет shared UUID";
return null;
}
function cbDuplicate() {
if (CB.draft.length !== 2) return false;
return CB.existing.some((c) => c.entry_node === CB.draft[0] && c.exit_node === CB.draft[1]);
}
function cbEl(code) {
return document.querySelector(`#cb-pool .cb-server[data-code="${code}"]`);
}
function cbRenderPool() {
const pool = document.getElementById("cb-pool");
pool.innerHTML = CB.nodes.map((n) => {
const flag = cbFlagOf(n.label);
const ico = flag || '<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>';
return `<div class="cb-node cb-server" data-code="${esc(n.code)}" title="${esc(n.label)}">
<span class="cb-badge"></span><span class="cb-x" title="Убрать из цепочки">×</span>
<span class="cb-port in"></span><span class="cb-port out"></span>
<div class="ico">${ico}</div>
<div><div class="nm">${esc(cbPlainName(n.label))}</div><div class="ad">${esc(n.address || n.code)}</div><div class="cb-lat" id="cb-lat-${esc(n.code)}"></div></div>
</div>`;
}).join("") || '<div class="empty" style="grid-column:1/-1">Нод пока нет — добавь их на странице «Ноды»</div>';
cbSyncNodes();
}
function cbSyncNodes() {
const tail = CB.draft[CB.draft.length - 1];
const nextPos = CB.draft.length;
CB.nodes.forEach((n) => {
const el = cbEl(n.code);
if (!el) return;
const idx = CB.draft.indexOf(n.code);
el.classList.toggle("in-chain", idx >= 0);
el.classList.toggle("tail", n.code === tail && !CB.dragging);
const badge = el.querySelector(".cb-badge");
if (badge) badge.textContent = idx >= 0 ? String(idx + 1) : "";
const reason = idx < 0 && nextPos < CB_MAX ? cbEligible(n, nextPos) : null;
el.classList.toggle("locked", !!reason);
el.title = reason ? n.label + " — " + reason : n.label;
});
const client = document.getElementById("cb-client");
client.classList.toggle("active", CB.draft.length > 0);
client.classList.toggle("tail", CB.draft.length === 0);
const net = document.getElementById("cb-internet");
net.classList.toggle("linked", CB.linked);
}
function cbEdge(el, side) {
const c = document.getElementById("cb-canvas").getBoundingClientRect();
const r = el.getBoundingClientRect();
if (side === "right") return { x: r.right - c.left, y: r.top + r.height / 2 - c.top, dir: [1, 0], side };
if (side === "left") return { x: r.left - c.left, y: r.top + r.height / 2 - c.top, dir: [-1, 0], side };
if (side === "bottom") return { x: r.left + r.width / 2 - c.left, y: r.bottom - c.top, dir: [0, 1], side };
return { x: r.left + r.width / 2 - c.left, y: r.top - c.top, dir: [0, -1], side };
}
function cbSides(aEl, bEl) {
const ra = aEl.getBoundingClientRect();
const rb = bEl.getBoundingClientRect();
const dx = rb.left + rb.width / 2 - (ra.left + ra.width / 2);
const dy = rb.top + rb.height / 2 - (ra.top + ra.height / 2);
if (Math.abs(dx) > ra.width * 0.7) return dx > 0 ? ["right", "left"] : ["left", "right"];
return dy > 0 ? ["bottom", "top"] : ["top", "bottom"];
}
function cbPath(a, b) {
const dist = Math.hypot(b.x - a.x, b.y - a.y);
const k = Math.max(50, dist / 2.2);
const bdir = b.dir || [-1, 0];
const c1 = [a.x + a.dir[0] * k, a.y + a.dir[1] * k];
const c2 = [b.x + bdir[0] * k, b.y + bdir[1] * k];
return `M ${a.x.toFixed(1)} ${a.y.toFixed(1)} C ${c1[0].toFixed(1)} ${c1[1].toFixed(1)}, ${c2[0].toFixed(1)} ${c2[1].toFixed(1)}, ${b.x.toFixed(1)} ${b.y.toFixed(1)}`;
}
function cbWireClass() {
if (CB.draft.length < 2) return "";
const level = CB.probe && CB.probe.result ? CB.probe.result.level : "unknown";
if (level === "high") return " high";
return " warn";
}
function cbPacket(d, len, delay) {
const dur = Math.max(1.2, len / 220);
return `<circle r="4" class="cb-packet"><animateMotion dur="${dur.toFixed(2)}s" begin="${delay}s" repeatCount="indefinite" path="${d}"/></circle>`;
}
function cbDraw() {
const svg = document.getElementById("cb-wires");
if (!svg) return;
const client = document.getElementById("cb-client");
const internet = document.getElementById("cb-internet");
const segments = [];
let prevEl = client;
let from = cbEdge(client, "right");
CB.draft.forEach((code) => {
const el = cbEl(code);
if (!el) return;
if (prevEl === client) {
segments.push([from, cbEdge(el, "left")]);
} else {
const sides = cbSides(prevEl, el);
segments.push([cbEdge(prevEl, sides[0]), cbEdge(el, sides[1])]);
}
from = cbEdge(el, "right");
prevEl = el;
});
if (CB.linked && CB.draft.length) {
const lastEl = cbEl(CB.draft[CB.draft.length - 1]);
if (lastEl) segments.push([cbEdge(lastEl, "right"), cbEdge(internet, "left")]);
}
let html = "";
const cls = cbWireClass();
segments.forEach(([a, b]) => {
const d = cbPath(a, b);
const len = Math.hypot(b.x - a.x, b.y - a.y) * 1.15;
html += `<path class="cb-wire-under" d="${d}"/><path class="cb-wire flow${cls}" d="${d}"/>`;
html += cbPacket(d, len, 0) + cbPacket(d, len, (Math.max(1.2, len / 220)) / 2);
[a, b].forEach((pt) => {
if (pt.side === "top" || pt.side === "bottom") html += `<circle class="cb-dot" cx="${pt.x.toFixed(1)}" cy="${pt.y.toFixed(1)}" r="5"/>`;
});
});
if (CB.dragging) {
const tailEl = CB.draft.length ? cbEl(CB.draft[CB.draft.length - 1]) : client;
const start = cbEdge(tailEl || client, "right");
const d = cbPath(start, { x: CB.pointer.x, y: CB.pointer.y, dir: [-1, 0] });
html += `<path class="cb-wire live${cls}" d="${d}"/>`;
}
svg.innerHTML = html;
}
function cbScheduleDraw() {
if (CB.raf) return;
CB.raf = requestAnimationFrame(() => { CB.raf = 0; cbDraw(); });
}
function cbEstimateKey() {
return CB.draft.length === 2 ? CB.draft.join(">") : "";
}
async function cbProbe() {
const key = cbEstimateKey();
if (!key) { CB.probe = null; return; }
if (CB.probe && CB.probe.key === key) return;
const token = ++CB.probeToken;
CB.probe = { key, loading: true, result: null };
cbRenderInspector();
try {
const res = await api(`/admin/api/chains/probe?entry=${encodeURIComponent(CB.draft[0])}&exit=${encodeURIComponent(CB.draft[1])}`);
if (token !== CB.probeToken) return;
CB.probe = { key, loading: false, result: res };
} catch (e) {
if (token !== CB.probeToken) return;
CB.probe = { key, loading: false, result: { rtt_ms: null, level: "unknown", error: e.message } };
}
cbRenderInspector();
cbDraw();
}
function cbCallout(kind, icon, html) {
return `<div class="cb-callout ${kind}">${icon}<div>${html}</div></div>`;
}
const CB_ICONS = {
info: '<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="M12 11v5M12 8h.01"/></svg>',
warn: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l10 18H2L12 3z"/><path d="M12 10v5M12 18h.01"/></svg>',
ok: '<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>',
};
function cbRenderInspector() {
const names = CB.draft.map((c) => cbNode(c)).filter(Boolean);
const steps = [
{ done: true, text: "Клиент" },
{ done: names.length >= 1, text: names[0] ? cbFlagOf(names[0].label) + " " + cbPlainName(names[0].label) : "Сервер 1 — вход" },
{ done: names.length >= 2, text: names[1] ? cbFlagOf(names[1].label) + " " + cbPlainName(names[1].label) : "Сервер 2 — выход (необязательно)" },
{ done: CB.linked, text: "Интернет" },
];
document.getElementById("cb-steps").innerHTML = steps.map((s, i) =>
`<div class="cb-step${s.done ? " done" : ""}"><span class="st">${s.done ? "✓" : i + 1}</span><span>${esc(s.text)}</span></div>`
).join("");
let callouts = "";
const count = names.length;
if (count === 0) {
callouts = cbCallout("info", CB_ICONS.info, "Зажми левую кнопку мыши на <b>Клиенте</b> и протяни провод к серверу из пула. Не отпуская, веди дальше — через второй сервер к <b>Интернету</b>.");
} else if (count === 1 && !CB.linked) {
callouts = cbCallout("info", CB_ICONS.info, "Теперь протяни провод от этого сервера: ко второму серверу (получится цепочка) или сразу к <b>Интернету</b>.");
} else if (count === 1 && CB.linked) {
callouts = cbCallout("ok", CB_ICONS.ok, "Это обычное подключение: <b>клиент → сервер → интернет</b>. Оно уже есть у каждой ноды, отдельная цепочка не нужна. Добавь второй сервер, чтобы построить цепочку.");
} else {
const probe = CB.probe;
let rttHtml = "";
let level = "warn";
if (probe && probe.loading) {
rttHtml = '<div style="margin-top:8px"><span class="skeleton" style="display:inline-block;width:150px;height:14px"></span></div>';
} else if (probe && probe.result) {
const r = probe.result;
if (r.rtt_ms === null || r.rtt_ms === undefined) {
rttHtml = '<div style="margin-top:8px">Замерить задержку между серверами не получилось' + (r.error ? " (" + esc(r.error) + ")" : "") + " — но она точно будет.</div>";
} else {
const word = r.level === "high" ? "высокая" : (r.level === "medium" ? "заметная" : "умеренная");
rttHtml = `<div style="margin-top:8px">Замер между серверами: <b class="mono">${r.rtt_ms} мс</b> — ${word} добавочная задержка.</div>`;
if (r.level === "high") level = "high";
}
}
callouts = cbCallout(level, CB_ICONS.warn,
"<b>Высокая задержка.</b> Трафик пойдёт <b>клиент → сервер 1 → сервер 2 → интернет</b>: к обычному пингу добавляется ещё один прыжок, а скорость ограничена самым слабым звеном. Бери соседние регионы." + rttHtml);
if (cbDuplicate()) {
callouts += cbCallout("high", CB_ICONS.warn, "Такая цепочка уже существует.");
} else if (!CB.linked) {
callouts += cbCallout("info", CB_ICONS.info, "Замкни провод на <b>Интернет</b>, чтобы создать цепочку.");
}
}
document.getElementById("cb-callouts").innerHTML = callouts;
const ready = count === 2 && CB.linked && !cbDuplicate();
document.getElementById("cb-create").disabled = !ready;
const labelInput = document.getElementById("cb-label");
labelInput.placeholder = count === 2 ? `${names[0].label} → ${names[1].label}` : "Название (необязательно)";
}
function cbChanged() {
cbSyncNodes();
cbRenderInspector();
cbDraw();
cbProbe();
}
function cbAdd(code) {
if (CB.draft.length >= CB_MAX) return "Максимум 2 сервера в цепочке";
if (CB.draft.includes(code)) return "Этот сервер уже в цепочке";
const reason = cbEligible(cbNode(code), CB.draft.length);
if (reason) return reason;
CB.draft.push(code);
cbChanged();
return null;
}
function cbRemoveFrom(code) {
const idx = CB.draft.indexOf(code);
if (idx < 0) return;
CB.draft = CB.draft.slice(0, idx);
if (!CB.draft.length) CB.linked = false;
cbChanged();
}
function cbLink() {
if (!CB.draft.length || CB.linked) return false;
CB.linked = true;
cbChanged();
return true;
}
function cbReset() {
CB.draft = [];
CB.linked = false;
CB.probe = null;
CB.probeToken++;
document.getElementById("cb-label").value = "";
cbChanged();
}
function cbReject(code, reason) {
const el = code ? cbEl(code) : null;
if (el) {
el.classList.remove("shake");
void el.offsetWidth;
el.classList.add("shake");
setTimeout(() => el.classList.remove("shake"), 500);
}
if (CB.lastReject !== code + reason) {
CB.lastReject = code + reason;
toast(reason, "warn");
}
}
function cbRelPointer(e) {
const r = document.getElementById("cb-canvas").getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function cbClearTargets() {
document.querySelectorAll("#cb-canvas .target").forEach((el) => el.classList.remove("target"));
}
function cbOnDown(e) {
if (e.button !== 0) return;
if (e.target.closest(".cb-x")) return;
const node = e.target.closest(".cb-node");
if (!node) return;
const tail = CB.draft[CB.draft.length - 1];
const fromClient = node.id === "cb-client";
const fromTail = node.classList.contains("cb-server") && node.dataset.code === tail && !CB.linked;
if (!fromClient && !fromTail) return;
if (fromClient && CB.draft.length) {
CB.draft = [];
CB.linked = false;
CB.probe = null;
CB.probeToken++;
}
e.preventDefault();
CB.dragging = true;
CB.lastReject = null;
CB.pointer = cbRelPointer(e);
document.getElementById("cb-canvas").classList.add("dragging");
cbChanged();
}
const CB_DWELL = 240;
function cbTargetAt(e) {
const under = document.elementFromPoint(e.clientX, e.clientY);
const node = under ? under.closest("#cb-canvas .cb-node") : null;
if (!node) return null;
if (node.classList.contains("cb-server")) {
const code = node.dataset.code;
if (CB.draft.includes(code)) return null;
return { key: "s:" + code, kind: "server", code, el: node };
}
if (node.id === "cb-internet") return { key: "internet", kind: "internet", el: node };
return null;
}
function cbRefuse(target) {
if (target.kind === "server") {
if (CB.draft.length >= CB_MAX) return "Максимум 2 сервера в цепочке";
return cbEligible(cbNode(target.code), CB.draft.length);
}
return CB.draft.length ? null : "Сначала протяни провод хотя бы через один сервер";
}
function cbCommit(target) {
if (target.kind === "server") {
cbAdd(target.code);
return false;
}
cbLink();
return true;
}
function cbHoverClear() {
if (CB.hover.timer) clearTimeout(CB.hover.timer);
CB.hover = { key: null, target: null, timer: 0 };
cbClearTargets();
}
function cbHoverCommit() {
const target = CB.hover.target;
if (!target || !CB.dragging) return;
cbHoverClear();
if (cbCommit(target)) cbEndDrag();
}
function cbOnMove(e) {
if (!CB.dragging) return;
CB.pointer = cbRelPointer(e);
const target = cbTargetAt(e);
const key = target ? target.key : null;
if (key !== CB.hover.key) {
cbHoverClear();
if (!target) {
CB.lastReject = null;
} else {
const reason = cbRefuse(target);
if (reason) {
cbReject(target.kind === "server" ? target.code : null, reason);
} else {
target.el.classList.add("target");
CB.hover = { key, target, timer: setTimeout(cbHoverCommit, CB_DWELL) };
}
}
}
cbScheduleDraw();
}
function cbEndDrag() {
if (!CB.dragging) return;
CB.dragging = false;
CB.suppressClick = true;
setTimeout(() => { CB.suppressClick = false; }, 0);
cbHoverClear();
document.getElementById("cb-canvas").classList.remove("dragging");
cbChanged();
}
function cbOnUp() {
if (!CB.dragging) return;
const target = CB.hover.target;
cbHoverClear();
if (target) cbCommit(target);
cbEndDrag();
}
function cbOnClick(e) {
if (CB.suppressClick) return;
const x = e.target.closest(".cb-x");
if (x) {
const node = x.closest(".cb-server");
if (node) cbRemoveFrom(node.dataset.code);
return;
}
const node = e.target.closest(".cb-node");
if (!node) return;
if (node.classList.contains("cb-server")) {
const code = node.dataset.code;
if (CB.draft.includes(code)) return;
const reason = cbAdd(code);
if (reason) cbReject(code, reason);
} else if (node.id === "cb-internet") {
if (!CB.draft.length) { cbReject(null, "Сначала добавь хотя бы один сервер"); return; }
cbLink();
}
}
async function cbCreate() {
if (CB.draft.length !== 2 || !CB.linked) return;
const btn = document.getElementById("cb-create");
btn.classList.add("loading");
try {
await api("/admin/api/chains", {
method: "POST",
body: JSON.stringify({ entry: CB.draft[0], exit: CB.draft[1], label: document.getElementById("cb-label").value.trim() }),
});
toast("Цепочка создана и применена на серверах", "success");
cbReset();
await loadChainsList();
} catch (e) {
toast(e.message, "error");
} finally {
btn.classList.remove("loading");
}
}
function cbWireLatency() {
CB.nodes.forEach((n) => {
const el = document.getElementById("cb-lat-" + n.code);
if (!el) return;
const ms = latencyCache[n.code];
if (!n.enabled || n.status !== "active") { el.textContent = ""; return; }
el.textContent = ms === null || ms === undefined ? "из панели: нет ответа" : "из панели: " + ms + " мс";
});
}
function chainNodeHtml(label) {
const flag = cbFlagOf(label);
return `<span class="cp-node">${flag || '<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"/></svg>'}${esc(cbPlainName(label))}</span>`;
}
const ICON_CLIENT = '<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="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>';
const ICON_GLOBE = '<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="M3 12h18M12 3c3 3.2 3 14.8 0 18M12 3c-3 3.2-3 14.8 0 18"/></svg>';
function renderChainsList() {
const box = document.getElementById("chains-list");
if (!CB.existing.length) {
box.innerHTML = '<div class="panel"><div class="empty" style="padding:30px 0">Цепочек пока нет — протяни провод в конструкторе выше</div></div>';
return;
}
box.innerHTML = CB.existing.map((c, i) => `
<div class="chain-card reveal${c.enabled ? "" : " off"}" style="animation-delay:${Math.min(i, 10) * 0.04}s" data-code="${esc(c.code)}">
<div class="chain-path">
<span class="cp-node" style="color:var(--accent)">${ICON_CLIENT}Клиент</span><span class="cp-link"></span>
${chainNodeHtml(c.entry_label)}<span class="cp-link"></span>
${chainNodeHtml(c.exit_label)}<span class="cp-link"></span>
<span class="cp-node" style="color:var(--blue)">${ICON_GLOBE}Интернет</span>
</div>
<div class="chain-meta"><div class="t">${esc(c.label)}</div><div class="s">порт ${c.port} · ${esc(c.code)}</div><div class="s" id="chain-check-${esc(c.code)}"></div></div>
<div class="chain-actions">
<label class="switch" title="${c.enabled ? "Выключить" : "Включить"}"><input type="checkbox" ${c.enabled ? "checked" : ""} onchange="toggleChain('${esc(c.code)}', this)"><span class="track"></span></label>
<button class="muted-btn" onclick="checkChain('${esc(c.code)}', this)">Проверить</button>
<button class="muted-btn red" onclick="deleteChain('${esc(c.code)}')">Удалить</button>
</div>
</div>`).join("");
}
async function loadChainsList() {
CB.existing = await api("/admin/api/chains");
renderChainsList();
const count = document.getElementById("nav-chains-count");
if (count) count.textContent = CB.existing.length ? String(CB.existing.length) : "";
cbRenderInspector();
}
async function toggleChain(code, input) {
const enabled = input.checked;
input.disabled = true;
try {
await api(`/admin/api/chains/${code}`, { method: "PATCH", body: JSON.stringify({ enabled }) });
toast(enabled ? "Цепочка включена" : "Цепочка выключена", "success");
} catch (e) {
input.checked = !enabled;
toast(e.message, "error");
} finally {
input.disabled = false;
loadChainsList();
}
}
async function checkChain(code, btn) {
const out = document.getElementById("chain-check-" + code);
btn.disabled = true;
out.textContent = "проверяю…";
try {
const r = await api(`/admin/api/chains/${code}/check`, { method: "POST" });
const hop = r.rtt_ms === null || r.rtt_ms === undefined ? "нет замера" : r.rtt_ms + " мс";
out.textContent = (r.entry_alive ? "вход отвечает" : "вход не отвечает") + " · переход между серверами: " + hop;
out.style.color = r.entry_alive ? "var(--green)" : "var(--red)";
} catch (e) {
out.textContent = "";
toast(e.message, "error");
} finally {
btn.disabled = false;
}
}
async function deleteChain(code) {
if (!(await askConfirm("Удалить цепочку? Она пропадёт из подписок, а настройки уберутся с обоих серверов."))) return;
try {
const r = await api(`/admin/api/chains/${code}`, { method: "DELETE" });
toast("Цепочка удалена", "success");
if (r.warnings && r.warnings.length) toast("Не всё удалось убрать сразу: " + r.warnings.join("; ") + ". Доделается при следующей синхронизации.", "warn", 8000);
} catch (e) {
toast(e.message, "error");
}
loadChainsList();
}
async function loadChains() {
if (!CB.ready) cbInit();
nodesCache = await api("/admin/api/nodes");
CB.nodes = nodesCache;
cbRenderPool();
cbDraw();
cbRenderInspector();
loadChainsList();
fetchLatency().then(() => { cbWireLatency(); }).catch(() => {});
}
function cbInit() {
CB.ready = true;
const canvas = document.getElementById("cb-canvas");
canvas.addEventListener("pointerdown", cbOnDown);
window.addEventListener("pointermove", cbOnMove);
window.addEventListener("pointerup", cbOnUp);
window.addEventListener("pointercancel", cbOnUp);
canvas.addEventListener("click", cbOnClick);
window.addEventListener("resize", cbScheduleDraw);
if (window.ResizeObserver) new ResizeObserver(cbScheduleDraw).observe(canvas);
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape" || CMDK.open) return;
if (!document.getElementById("view-chains").classList.contains("active")) return;
if (document.activeElement && document.activeElement.tagName === "INPUT") return;
cbReset();
});
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
}
(async function init() {
initDropdowns();
initAccent();
initCmdk();
feat: custom brand name everywhere + a working client-facing site out of the box User ask, paraphrased: install it, get help wiring up payments, and immediately have a ready site under your own name — not "MBS Panel" plastered everywhere and a bunch of manual follow-up. Two things were actually broken/missing, found by tracing every surface a real customer or the operator would see: 1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription page, admin panel splash/title/sidebar, legal pages, 2FA issuer, install.sh) with zero way to change it short of editing source. New BRAND_NAME config value (config.py default "MBS Panel", so this is 100% backward compatible for existing installs) wired through everywhere via the same live-settings pattern from the last commit (settings.get_brand_name(), no restart needed anywhere it's used). New Настройки → «Название» section in the admin panel to change it. 2. site/index.html and site/cabinet.html — a fully-built landing page + personal-cabinet template, already in the repo — were never actually served by anything. Not mounted by FastAPI, not deployed by install.sh, not linked from anywhere. Pure dead weight: a repo that looked like it shipped a client site but didn't. Now legal.py gets a render_site_page() (same {{TOKEN}} substitution + HTML-escaping as the existing offer/privacy renderer, new tokens: BRAND_NAME, SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded landing page on any host that isn't PANEL_DOMAIN (in practice: SUB_DOMAIN, which nginx already routes to this backend — zero install.sh/nginx/certbot changes needed, so this is live on every existing install without an upgrade step beyond `mbs update`). GET /cabinet.html serves the cabinet. Landing page's pricing section now fetches real, live prices from a new public GET /api/plans instead of showing static duration labels with no numbers. Also fixed along the way, same staleness-bug class as the payments/HWID fix last commit, found by grepping for every remaining frozen `from config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen constants in api.py (mbs-api never restarts itself). Concretely this meant: changing the bot via Настройки → Telegram-бот would leave _tg_send_message (payment-received notifications) silently trying the OLD token, admin_get_bot_settings showing the OLD username right after a successful save, and gift-code links pointing at the OLD bot — all until a manual mbs restart, same shape as the Platega-secret bug fixed last commit. Added settings.bot_credentials(), wired it through every call site (hoisted out of loops where relevant, same N+1 discipline as always), removed the now-stale "выполни mbs restart" copy from the bot settings hint. legal.py's own BOT_USERNAME import was frozen too (used by the /offer and /privacy {{BOT_USERNAME}} token) — switched to reading it live in-module (no settings.py import from legal.py, would've been circular since settings.py already imports legal.py for the env reader). install.sh: new interactive prompt for the brand name (default "MBS Panel", so hitting enter reproduces today's behavior exactly), written to .env, echoed in the final summary along with the now-live site URL. Verification: same story as always — api.py/bot.py still can't import locally (no pydantic-core wheel for Python 3.14 on this machine). py_compile + pyflakes clean across the whole repo. Real runtime test against an isolated .env fixture: brand name and bot-credential live reads (no reimport), render_site_page() token substitution correctness on the actual site/index.html and site/cabinet.html files including an XSS check (brand name containing <script> comes out HTML-escaped), and a regression check that adding the BRAND_NAME token to the existing legal.render() didn't break offer.html/privacy.html. Extracted SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't import the module, but can pull the string constants) and ran the real .format() calls against them to catch any brace-escaping mistake in the new {brand_name} placeholder — CSS braces in those templates are already double-escaped for .format(), easy to get wrong. Extracted and node --check'd admin.html's whole inline script, div-tag-balance check on the full file. install.sh's new prompt+heredoc snippet run standalone with piped stdin (both a brand name with spaces and an empty/default input), round-tripped the resulting .env back through the real env-parsing logic. Extended the existing CI "app wiring" step (which does import api/bot for real on Linux) with branding assertions calling the actual route functions directly (api.root(), api.public_plans(), api.public_branding()) — ran every part of that step's new logic that doesn't need api.py locally first, to catch what's catchable before trusting the rest to CI once the account's abuse-review lifts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00
fetch("/api/branding").then((r) => r.json()).then((d) => applyBrandName(d.brand_name)).catch(() => {});
try {
const me = await api("/admin/api/me");
if (me.authenticated) showApp(); else showLogin();
} catch (e) {
showLogin();
}
})();
</script>
</body>
</html>