3356 lines
207 KiB
HTML
3356 lines
207 KiB
HTML
<!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>
|
||
<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">
|
||
<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>
|
||
<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>
|
||
</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"> </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()">×</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()">×</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"> </label><button class="btn" onclick="grantSubscription()">Выдать</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="uc-tab-devices" style="display:none">
|
||
<div id="devices-list" class="devices-list"></div>
|
||
<label class="f">Лимит устройств для этого юзера</label>
|
||
<input type="text" id="devices-limit" placeholder="по умолчанию">
|
||
<p class="page-sub" id="devices-limit-hint" style="margin:6px 0 0"></p>
|
||
<div class="modal-actions">
|
||
<button class="btn" onclick="saveHwidLimit()">Сохранить лимит</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-traffic" class="view">
|
||
<div class="page-title">Трафик</div>
|
||
<div class="page-sub">Суммарно по всем нодам, live через Xray Stats API</div>
|
||
<div class="stat-grid" id="traffic-stat-grid" style="grid-template-columns:repeat(3,1fr)"></div>
|
||
<div class="section">
|
||
<div class="section-head"><h2>По подпискам</h2></div>
|
||
<div class="table-wrap"><table><thead><tr>
|
||
<th>Пользователь</th><th>Сервер</th><th>Входящий</th><th>Исходящий</th><th>Всего</th><th></th>
|
||
</tr></thead><tbody id="traffic-body"></tbody></table></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-payments" class="view">
|
||
<div class="page-title">Платежи</div>
|
||
<div class="page-sub">ЮKassa / Platega — история и статус, с проверкой на стороне провайдера при пропущенном вебхуке</div>
|
||
|
||
<div class="section">
|
||
<div class="section-head"><h2>Настройка приёма платежей</h2></div>
|
||
<p class="page-sub" style="margin-bottom:16px">Заполни один раз — панель сама соберёт из этого публичную оферту и политику конфиденциальности (обязательны для подключения ЮKassa) на своих страницах, готовых к показу клиентам.</p>
|
||
|
||
<div class="doc-block" style="margin-bottom:20px">
|
||
<h2>Как это работает</h2>
|
||
<p>1. Заполни реквизиты ниже (кто ты для закона — самозанятый/ИП/ООО, ИНН, контакты). Это те же данные, что ЮKassa попросит при регистрации магазина.</p>
|
||
<p>2. Подключи ЮKassa: заведи магазин на <a href="https://yookassa.ru" target="_blank">yookassa.ru</a>, в личном кабинете возьми <b>shop_id</b> и <b>секретный ключ</b> (Настройки → Ключи API), вставь сюда. Панель сразу проверит их и сохранит.</p>
|
||
<p>3. Ссылки на готовые оферту и политику (<code>https://{домен}/offer</code>, <code>/privacy</code>) — дай их ЮKassa при регистрации магазина, она их обязательно спросит.</p>
|
||
<p class="muted">Самозанятым для приёма платежей от физлиц регистрация магазина в ЮKassa доступна напрямую по паспорту и ИНН, без онлайн-кассы — она уже встроена в сервис ЮKassa. ИП/ООО — обычная регистрация магазина.</p>
|
||
</div>
|
||
|
||
<h3 style="font-size:14px;margin:0 0 12px">Реквизиты для документов</h3>
|
||
<div class="form-row">
|
||
<div><label class="f">Кто ты</label><div id="legal-type" class="dd"></div></div>
|
||
<div><label class="f" id="legal-name-label">ФИО</label><input type="text" id="legal-name" placeholder="Иванов Иван Иванович"></div>
|
||
<div><label class="f">ИНН</label><input type="text" id="legal-inn" placeholder="770123456789"></div>
|
||
</div>
|
||
<div class="form-row" style="margin-top:12px">
|
||
<div><label class="f">Email поддержки</label><input type="text" id="legal-email" placeholder="support@example.com"></div>
|
||
<div><label class="f">Telegram-контакт поддержки</label><input type="text" id="legal-contact" placeholder="@support"></div>
|
||
<div><label class="f">Возврат в течение (часов)</label><input type="text" id="legal-refund" placeholder="24"></div>
|
||
</div>
|
||
<div class="form-row" style="margin-top:12px">
|
||
<button class="btn" onclick="saveLegalSettings()">Сохранить реквизиты</button>
|
||
</div>
|
||
<div id="legal-result"></div>
|
||
<p class="check-hint">Страницы всегда доступны по ссылкам: <a href="/offer" target="_blank" id="legal-offer-link">/offer</a> · <a href="/privacy" target="_blank" id="legal-privacy-link">/privacy</a> — незаполненные поля показываются пометкой, что их надо указать, страница не ломается.</p>
|
||
|
||
<h3 style="font-size:14px;margin:24px 0 12px">ЮKassa — ключи API</h3>
|
||
<div id="yookassa-status" class="page-sub" style="margin-bottom:12px"></div>
|
||
<div class="form-row">
|
||
<div><label class="f">shop_id</label><input type="text" id="yk-shop-id" placeholder="123456"></div>
|
||
<div><label class="f">Секретный ключ</label><input type="password" id="yk-secret-key" placeholder="live_..."></div>
|
||
<div style="flex:0"><label class="f"> </label><button class="btn" onclick="saveYookassaSettings()">Проверить и сохранить</button></div>
|
||
</div>
|
||
<p class="check-hint">Панель сама постучится в ЮKassa (<code>/v3/me</code>) и сохранит ключи только если они рабочие. После сохранения сразу включаются приём оплаты и приём вебхуков — без рестарта; бот на всякий случай перезапускается сам, чтобы кнопки оплаты в Telegram тоже обновились немедленно.</p>
|
||
<div id="yookassa-result"></div>
|
||
|
||
<h3 style="font-size:14px;margin:24px 0 12px">Platega — ключи API</h3>
|
||
<div id="platega-status" class="page-sub" style="margin-bottom:12px"></div>
|
||
<div class="form-row">
|
||
<div><label class="f">Merchant ID</label><input type="text" id="pg-merchant-id" placeholder="merchant_..."></div>
|
||
<div><label class="f">Секрет</label><input type="password" id="pg-secret" placeholder="secret_..."></div>
|
||
<div style="flex:0"><label class="f"> </label><button class="btn" onclick="savePlategaSettings()">Сохранить</button></div>
|
||
</div>
|
||
<p class="check-hint">У Platega нет публичного эндпоинта для проверки ключей без реального платежа, так что сохраняется без предварительной проверки — если ключи неверные, это будет видно по первой неудачной оплате. Применяется сразу, без рестарта.</p>
|
||
<div id="platega-result"></div>
|
||
</div>
|
||
|
||
<div class="section" style="margin-top:20px">
|
||
<div class="section-head"><h2>Тарифы</h2></div>
|
||
<p class="page-sub" style="margin-bottom:16px">Цены по срокам подписки и общий приём оплаты — меняются здесь, применяются сразу, рестарт не нужен.</p>
|
||
<label class="check"><input type="checkbox" id="plan-payments-enabled"> Принимать оплату (если выключено — бот всегда выдаёт подписку бесплатно, как без платёжки вообще)</label>
|
||
<div class="form-row" style="margin-top:12px" id="plan-price-inputs"></div>
|
||
<div class="form-row" style="margin-top:12px">
|
||
<button class="btn" onclick="savePlanSettings()">Сохранить тарифы</button>
|
||
</div>
|
||
<div id="plan-settings-result"></div>
|
||
</div>
|
||
|
||
<div class="section" style="margin-top:20px">
|
||
<div class="section-head"><h2>История</h2></div>
|
||
<div class="table-wrap"><table><thead><tr>
|
||
<th>Пользователь</th><th>Сервер</th><th>Тариф</th><th>Провайдер</th><th>Сумма</th><th>Создан</th><th>Статус</th><th></th>
|
||
</tr></thead><tbody id="payments-body"></tbody></table></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-docs" class="view">
|
||
<div class="page-title">Документация</div>
|
||
<div class="page-sub">Как устроена панель и как её обслуживать — без похода на GitHub</div>
|
||
|
||
<div class="doc-block">
|
||
<h2>Архитектура</h2>
|
||
<p>Панель — три процесса: <b>bot.py</b> (телеграм-бот, aiogram) и <b>api.py</b> (FastAPI — админка + выдача подписок) читают одну SQLite-базу; <b>Xray</b> — отдельный процесс, который реально гоняет трафик. Панель никогда не проксирует VPN-трафик сама, только управляет конфигом Xray и читает его статистику через встроенный Stats API.</p>
|
||
<p>На 443 порту одновременно живёт и настоящий HTTPS (для сайта/подписки), и замаскированный под HTTPS VLESS+Reality — их разводит <code>nginx stream</code> модуль по SNI входящего TLS-соединения, до расшифровки.</p>
|
||
</div>
|
||
|
||
<div class="doc-block">
|
||
<h2>Название и клиентский сайт</h2>
|
||
<p>Настройки → «Название» — своё название бренда вместо дефолтного «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 <(curl ...)></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>
|
||
<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>
|
||
<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>
|
||
<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>
|
||
<p>Вкладка Платежи → «Тарифы» — цены по срокам и общий рубильник приёма оплаты. Как и реквизиты с ключами провайдеров, это читается панелью напрямую из <code>.env</code> при каждом запросе — правки в UI применяются мгновенно везде (бот, API, проверка вебхуков), рестарт панели нигде не требуется.</p>
|
||
</div>
|
||
|
||
<div class="doc-block">
|
||
<h2>Лимит устройств (HWID)</h2>
|
||
<p>Настройки → «Лимит устройств» — глобальный рубильник и лимит по умолчанию (как у Remnawave: клиент шлёт заголовок <code>x-hwid</code> при запросе конфига, панель запоминает первые N уникальных устройств на юзера и отказывает новым сверх лимита). У конкретного юзера лимит можно переопределить отдельно — в его карточке (Подписки → кнопка «Карточка» → таб «Устройства»), это имеет приоритет над глобальным значением по умолчанию.</p>
|
||
</div>
|
||
|
||
<div class="doc-block">
|
||
<h2>Пауза подписки</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">
|
||
<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"> </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"> </label><button class="btn" onclick="saveBotSettings()">Сменить бота</button></div>
|
||
</div>
|
||
<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"> </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"> </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"> </label><button class="btn" style="background:var(--red)" onclick="confirmDisableTotp()">Отключить</button></div>
|
||
</div>
|
||
</div>
|
||
<div id="totp-result"></div>
|
||
</div>
|
||
|
||
<div class="section" style="margin-top:20px">
|
||
<div class="section-head"><h2>Бэкап и восстановление</h2></div>
|
||
<p class="page-sub" style="margin-bottom:16px">Бэкап — это база (юзеры, подписки, ноды, платежи) и <code>.env</code> одним файлом. Держи копии где-то отдельно от сервера.</p>
|
||
<div class="form-row" style="align-items:flex-start">
|
||
<button class="btn" onclick="downloadBackup()">Скачать бэкап</button>
|
||
</div>
|
||
<div style="margin-top:20px;padding-top:20px;border-top:1px solid var(--border)">
|
||
<label class="f">Восстановить из файла</label>
|
||
<div class="form-row">
|
||
<input type="file" id="restore-file-input" accept=".gz,.tar.gz">
|
||
<div style="flex:0"><button class="btn" style="background:var(--red)" onclick="restoreBackup()">Восстановить</button></div>
|
||
</div>
|
||
<p class="check-hint">⚠ Заменяет текущую базу целиком. Перед заменой панель сама делает копию текущей базы на сервере (файл <code>.before-restore-...</code>), но проверь, что заливаешь именно тот файл, что нужно.</p>
|
||
</div>
|
||
<div id="backup-result"></div>
|
||
</div>
|
||
|
||
<div class="section" style="margin-top:20px">
|
||
<div class="section-head"><h2>Webhook на события</h2></div>
|
||
<p class="page-sub" style="margin-bottom:16px">Панель сама постучится на твой URL при оплате или ручной выдаче подписки — для своих интеграций (CRM, аналитика, что угодно), без опроса API.</p>
|
||
<div class="form-row">
|
||
<div><label class="f">URL</label><input type="text" id="webhook-url" placeholder="https://example.com/hook"></div>
|
||
<div style="flex:0"><label class="f"> </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 от тела запроса на секрете ниже) — так получатель проверяет, что запрос реально от панели.
|
||
Секрет для проверки: <code id="webhook-secret-display">—</code>
|
||
</p>
|
||
<div id="webhook-result"></div>
|
||
</div>
|
||
|
||
<div class="section" style="margin-top:20px">
|
||
<div class="section-head"><h2>Лимит устройств (HWID)</h2></div>
|
||
<p class="page-sub" style="margin-bottom:16px">Ограничивает число разных устройств на одну подписку — как у Remnawave. У конкретного юзера лимит можно переопределить в его карточке, это значение — только дефолт для тех, у кого свой не задан.</p>
|
||
<label class="check"><input type="checkbox" id="hwid-enabled"> Включить лимит устройств</label>
|
||
<div class="form-row" style="margin-top:12px">
|
||
<div><label class="f">Лимит устройств по умолчанию</label><input type="text" id="hwid-fallback-limit" placeholder="3"></div>
|
||
<div style="flex:0"><label class="f"> </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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
}
|
||
async function api(path, opts) {
|
||
const res = await fetch(path, { ...opts, headers: { "Content-Type": "application/json", ...(opts && opts.headers) } });
|
||
if (res.status === 401) { showLogin(); throw new Error("unauthorized"); }
|
||
if (!res.ok) {
|
||
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();
|
||
if (name === "payments") { loadPayments(); loadPaymentsSettings(); }
|
||
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}`,
|
||
}));
|
||
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(),
|
||
});
|
||
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();
|
||
}
|
||
}
|
||
|
||
async function loadPaymentsSettings() {
|
||
const [legalRes, ykRes, pgRes, planRes] = await Promise.all([
|
||
api("/admin/api/payments/legal-settings"),
|
||
api("/admin/api/payments/yookassa-settings"),
|
||
api("/admin/api/payments/platega-settings"),
|
||
api("/admin/api/payments/plan-settings"),
|
||
]);
|
||
document.getElementById("legal-name").value = legalRes.LEGAL_NAME || "";
|
||
document.getElementById("legal-inn").value = legalRes.LEGAL_INN || "";
|
||
document.getElementById("legal-refund").value = legalRes.REFUND_HOURS || "24";
|
||
document.getElementById("legal-contact").value = legalRes.SUPPORT_CONTACT || "";
|
||
document.getElementById("legal-email").value = legalRes.SUPPORT_EMAIL || "";
|
||
|
||
document.getElementById("yk-shop-id").value = ykRes.shop_id || "";
|
||
const ykStatus = document.getElementById("yookassa-status");
|
||
if (ykRes.enabled && ykRes.has_secret) {
|
||
ykStatus.innerHTML = '<span class="badge ok">подключена</span> shop_id: ' + esc(ykRes.shop_id);
|
||
} else {
|
||
ykStatus.innerHTML = '<span class="badge bad">не настроена</span>';
|
||
}
|
||
|
||
document.getElementById("pg-merchant-id").value = pgRes.merchant_id || "";
|
||
const pgStatus = document.getElementById("platega-status");
|
||
if (pgRes.enabled && pgRes.has_secret) {
|
||
pgStatus.innerHTML = '<span class="badge ok">подключена</span> merchant_id: ' + esc(pgRes.merchant_id);
|
||
} else {
|
||
pgStatus.innerHTML = '<span class="badge bad">не настроена</span>';
|
||
}
|
||
|
||
document.getElementById("plan-payments-enabled").checked = !!planRes.payments_enabled;
|
||
document.getElementById("plan-price-inputs").innerHTML = planRes.plans.map((p) => `
|
||
<div><label class="f">${esc(p.label)}</label><input type="text" data-plan-code="${esc(p.code)}" class="plan-price-input" value="${p.price}"></div>
|
||
`).join("");
|
||
}
|
||
|
||
async function saveLegalSettings() {
|
||
const result = document.getElementById("legal-result");
|
||
const body = {
|
||
LEGAL_NAME: document.getElementById("legal-name").value.trim(),
|
||
LEGAL_INN: document.getElementById("legal-inn").value.trim(),
|
||
REFUND_HOURS: document.getElementById("legal-refund").value.trim() || "24",
|
||
SUPPORT_CONTACT: document.getElementById("legal-contact").value.trim(),
|
||
SUPPORT_EMAIL: document.getElementById("legal-email").value.trim(),
|
||
};
|
||
try {
|
||
await api("/admin/api/payments/legal-settings", { method: "POST", body: JSON.stringify(body) });
|
||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено — страницы /offer и /privacy обновились сразу, без рестарта</p>';
|
||
} catch (e) {
|
||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||
}
|
||
}
|
||
|
||
async function saveYookassaSettings() {
|
||
const shop_id = document.getElementById("yk-shop-id").value.trim();
|
||
const secret_key = document.getElementById("yk-secret-key").value.trim();
|
||
const result = document.getElementById("yookassa-result");
|
||
if (!shop_id || !secret_key) return;
|
||
result.innerHTML = '<p class="page-sub" style="margin-top:10px">Проверяю ключи у ЮKassa…</p>';
|
||
try {
|
||
await api("/admin/api/payments/yookassa-settings", { method: "POST", body: JSON.stringify({ shop_id, secret_key }) });
|
||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Ключи рабочие, сохранено — бот и приём вебхуков подхватывают их сразу, рестарт не нужен.</p>';
|
||
document.getElementById("yk-secret-key").value = "";
|
||
loadPaymentsSettings();
|
||
} catch (e) {
|
||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||
}
|
||
}
|
||
|
||
async function savePlategaSettings() {
|
||
const merchant_id = document.getElementById("pg-merchant-id").value.trim();
|
||
const secret = document.getElementById("pg-secret").value.trim();
|
||
const result = document.getElementById("platega-result");
|
||
if (!merchant_id || !secret) return;
|
||
try {
|
||
await api("/admin/api/payments/platega-settings", { method: "POST", body: JSON.stringify({ merchant_id, secret }) });
|
||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено — бот и приём вебхуков подхватывают ключи сразу, рестарт не нужен.</p>';
|
||
document.getElementById("pg-secret").value = "";
|
||
loadPaymentsSettings();
|
||
} catch (e) {
|
||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||
}
|
||
}
|
||
|
||
async function savePlanSettings() {
|
||
const result = document.getElementById("plan-settings-result");
|
||
const prices = {};
|
||
document.querySelectorAll(".plan-price-input").forEach((el) => {
|
||
prices[el.dataset.planCode] = el.value.trim();
|
||
});
|
||
const body = {
|
||
payments_enabled: document.getElementById("plan-payments-enabled").checked,
|
||
prices,
|
||
};
|
||
try {
|
||
await api("/admin/api/payments/plan-settings", { method: "POST", body: JSON.stringify(body) });
|
||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено — применилось сразу, без рестарта</p>';
|
||
loadPaymentsSettings();
|
||
} catch (e) {
|
||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||
}
|
||
}
|
||
|
||
async function loadHwidSettings() {
|
||
const res = await api("/admin/api/hwid-settings");
|
||
document.getElementById("hwid-enabled").checked = !!res.enabled;
|
||
document.getElementById("hwid-fallback-limit").value = res.fallback_limit;
|
||
document.getElementById("hwid-result").innerHTML = "";
|
||
}
|
||
|
||
async function saveHwidSettings() {
|
||
const result = document.getElementById("hwid-result");
|
||
const body = {
|
||
enabled: document.getElementById("hwid-enabled").checked,
|
||
fallback_limit: document.getElementById("hwid-fallback-limit").value.trim(),
|
||
};
|
||
try {
|
||
await api("/admin/api/hwid-settings", { method: "POST", body: JSON.stringify(body) });
|
||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено — применилось сразу, без рестарта</p>';
|
||
loadHwidSettings();
|
||
} catch (e) {
|
||
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||
}
|
||
}
|
||
|
||
async function 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() {
|
||
let res;
|
||
try {
|
||
res = await api("/admin/api/2fa/setup", { method: "POST" });
|
||
} catch (e) {
|
||
toast("Не получилось: " + e.message, "error");
|
||
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>';
|
||
}
|
||
}
|
||
|
||
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) : "—"; }
|
||
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();
|
||
|
||
const recent = await api("/admin/api/subscriptions?limit=8");
|
||
const body = document.getElementById("recent-subs-body");
|
||
body.innerHTML = recent.length ? recent.map((s, i) => `
|
||
<tr ${rowAttr(i)}><td>${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}</td><td>${esc(s.node_label)}</td><td>${esc(s.plan_label)}</td>
|
||
<td>${fmtDate(s.expires_at)}</td><td>${statusBadge(s.active, s.days_left, s.held_at)}</td></tr>
|
||
`).join("") : '<tr><td colspan="5"><div class="empty">Пока нет подписок</div></td></tr>';
|
||
}
|
||
|
||
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>
|
||
<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>
|
||
${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>';
|
||
}
|
||
|
||
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";
|
||
}
|
||
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>
|
||
<div class="page-sub" style="margin:2px 0 0">до ${fmtDate(s.expires_at)} · ${statusBadge(s.active, s.days_left, s.held_at)}</div>
|
||
</div>
|
||
${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;
|
||
try {
|
||
await api(`/admin/api/users/${devicesTgId}/grant`, { method: "POST", body: JSON.stringify({ node, plan }) });
|
||
toast("Подписка выдана", "success");
|
||
} catch (e) {
|
||
toast("Не получилось: " + e.message, "error");
|
||
}
|
||
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) {
|
||
try {
|
||
await api(`/admin/api/users/${devicesTgId}/devices/${deviceId}`, { method: "DELETE" });
|
||
} catch (e) {
|
||
toast("Не получилось: " + e.message, "error");
|
||
}
|
||
const data = await api(`/admin/api/users/${devicesTgId}`);
|
||
renderDevices(data.devices);
|
||
}
|
||
async function saveHwidLimit() {
|
||
const val = document.getElementById("devices-limit").value.trim();
|
||
try {
|
||
await api(`/admin/api/users/${devicesTgId}/hwid-limit`, { method: "POST", body: JSON.stringify({ limit: val || null }) });
|
||
} catch (e) {
|
||
toast("Не получилось: " + e.message, "error");
|
||
}
|
||
}
|
||
|
||
async function revokeSub(uuid) {
|
||
if (!(await askConfirm("Отозвать подписку? Доступ пропадёт сразу, вернуть можно только новой выдачей.", { ok: "Отозвать" }))) return;
|
||
try {
|
||
await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" });
|
||
} catch (e) {
|
||
toast("Не получилось: " + e.message, "error");
|
||
}
|
||
loadSubscriptions();
|
||
}
|
||
|
||
async function holdSub(uuid) {
|
||
try {
|
||
await api(`/admin/api/subscriptions/${uuid}/hold`, { method: "POST" });
|
||
} catch (e) {
|
||
toast("Не получилось: " + e.message, "error");
|
||
}
|
||
loadSubscriptions();
|
||
}
|
||
|
||
async function resumeSub(uuid) {
|
||
try {
|
||
await api(`/admin/api/subscriptions/${uuid}/resume`, { method: "POST" });
|
||
} catch (e) {
|
||
toast("Не получилось: " + e.message, "error");
|
||
}
|
||
loadSubscriptions();
|
||
}
|
||
|
||
let plansCache = null, nodesCache = null;
|
||
|
||
async function loadGifts() {
|
||
if (!plansCache) plansCache = await api("/admin/api/plans");
|
||
nodesCache = await api("/admin/api/nodes");
|
||
const nodeOptions = nodesCache.filter((n) => n.enabled).map((n) => ({ value: n.code, label: n.label }));
|
||
const planOptions = plansCache.map((p) => ({ value: p.code, label: p.label }));
|
||
DD.giftNode.setOptions(nodeOptions);
|
||
DD.giftPlan.setOptions(planOptions);
|
||
if (!DD.giftNode.getValue() && nodeOptions.length) DD.giftNode.setValue(nodeOptions[0].value);
|
||
if (!DD.giftPlan.getValue() && planOptions.length) DD.giftPlan.setValue(planOptions[0].value);
|
||
|
||
const codes = await api("/admin/api/gift-codes");
|
||
const body = document.getElementById("gifts-body");
|
||
body.innerHTML = codes.length ? codes.map((c, i) => `
|
||
<tr ${rowAttr(i)}><td>${esc(c.node_label)}</td><td>${esc(c.plan_label)}</td><td>${fmtDate(c.created_at)}</td>
|
||
<td>${c.used_by ? '<span class="badge bad">использован</span>' : '<span class="badge ok">свободен</span>'}</td>
|
||
<td><button class="muted-btn" onclick="copyText('${c.link}')">Скопировать</button></td></tr>
|
||
`).join("") : '<tr><td colspan="5"><div class="empty">Пока нет гифт-кодов</div></td></tr>';
|
||
}
|
||
|
||
async function createGift() {
|
||
const node = DD.giftNode.getValue();
|
||
const plan = DD.giftPlan.getValue();
|
||
const 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) {
|
||
try {
|
||
await api(`/admin/api/nodes/${code}`, { method: "PATCH", body: JSON.stringify({ enabled }) });
|
||
} catch (e) {
|
||
toast("Не получилось: " + e.message, "error");
|
||
}
|
||
loadNodes();
|
||
}
|
||
async function deleteNode(code) {
|
||
if (!(await askConfirm("Удалить ноду? Это нельзя отменить.", { ok: "Удалить" }))) return;
|
||
try {
|
||
await api(`/admin/api/nodes/${code}`, { method: "DELETE" });
|
||
} catch (e) {
|
||
toast("Не получилось: " + e.message, "error");
|
||
}
|
||
loadNodes();
|
||
}
|
||
|
||
function switchNodeTab(tab) {
|
||
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === tab));
|
||
document.getElementById("node-tab-guide").style.display = tab === "guide" ? "block" : "none";
|
||
document.getElementById("node-tab-manual").style.display = tab === "manual" ? "block" : "none";
|
||
}
|
||
|
||
let pollTimer = null;
|
||
async function generateGuide() {
|
||
const label = document.getElementById("ng-label").value.trim();
|
||
const address = document.getElementById("ng-address").value.trim();
|
||
const port = parseInt(document.getElementById("ng-port").value || "443");
|
||
const sni = document.getElementById("ng-sni").value.trim();
|
||
const include_ws = document.getElementById("ng-ws").checked;
|
||
const include_hysteria2 = document.getElementById("ng-hy").checked;
|
||
const hysteria_port = parseInt(document.getElementById("ng-hy-port").value || "443");
|
||
if (!label || !address) return;
|
||
const 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);
|
||
let pollAttempts = 0, pollErrors = 0;
|
||
pollTimer = setInterval(async () => {
|
||
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();
|
||
} 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",
|
||
};
|
||
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>';
|
||
}
|
||
}
|
||
|
||
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();
|
||
});
|
||
}
|
||
|
||
(async function init() {
|
||
initDropdowns();
|
||
initAccent();
|
||
initCmdk();
|
||
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>
|