diff --git a/admin/assets/app.css b/admin/assets/app.css
new file mode 100644
index 0000000..18ba131
--- /dev/null
+++ b/admin/assets/app.css
@@ -0,0 +1,300 @@
+
+:root{
+ --bg:#0f172a;--bg2:#111827;--card:#020617;--accent:#3b82f6;
+ --accent-soft:rgba(59,130,246,.15);--border:#1f2937;--text:#e5e7eb;
+ --muted:#9ca3af;--danger:#ef4444;--success:#22c55e;--warn:#f59e0b;
+}
+*{box-sizing:border-box;margin:0;padding:0;}
+body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:radial-gradient(circle at top,#1e293b,#020617 50%);color:var(--text);min-height:100vh;}
+.app{padding:10px;}
+@media(min-width:768px){.app{padding:20px;}}
+
+/* ── Shell ── */
+.shell{max-width:1100px;margin:0 auto;background:linear-gradient(145deg,rgba(15,23,42,.97),rgba(15,23,42,.99));border-radius:16px;border:1px solid rgba(148,163,184,.1);box-shadow:0 18px 60px rgba(15,23,42,.9);padding:14px;}
+@media(min-width:768px){.shell{padding:20px;}}
+
+/* ── Header ── */
+header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:14px;padding-bottom:12px;border-bottom:1px solid var(--border);}
+.logo{font-size:1.1rem;font-weight:700;letter-spacing:.03em;}
+.logo span{color:var(--accent);}
+nav{display:flex;gap:4px;flex-wrap:wrap;}
+.tab-btn{background:transparent;border:1px solid transparent;border-radius:999px;padding:5px 12px;font-size:.75rem;color:var(--muted);cursor:pointer;}
+.tab-btn:hover{border-color:var(--border);color:var(--text);}
+.tab-btn.active{background:var(--accent-soft);border-color:var(--accent);color:var(--accent);}
+.hright{display:flex;align-items:center;gap:8px;font-size:.75rem;color:var(--muted);}
+.hright strong{color:var(--text);}
+
+/* ── Cards / Grid ── */
+.grid2{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:12px;}
+@media(max-width:860px){.grid2{grid-template-columns:1fr;}}
+.card{background:radial-gradient(circle at top left,rgba(59,130,246,.07),var(--card));border-radius:14px;border:1px solid rgba(31,41,55,.95);padding:12px;position:relative;}
+.card+.card{margin-top:12px;}
+.card-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;gap:6px;flex-wrap:wrap;}
+.card-title{font-size:.85rem;font-weight:600;display:flex;align-items:center;gap:6px;}
+.chip{font-size:.62rem;padding:2px 7px;border-radius:999px;background:rgba(15,23,42,.85);border:1px solid rgba(55,65,81,.9);color:var(--muted);}
+.chip.green{border-color:rgba(34,197,94,.5);color:var(--success);}
+.chip.red{border-color:rgba(239,68,68,.5);color:var(--danger);}
+.chip.warn{border-color:rgba(245,158,11,.5);color:var(--warn);}
+
+/* ── Buttons ── */
+.btn{border:none;border-radius:999px;padding:5px 11px;font-size:.72rem;font-weight:500;background:var(--accent);color:#fff;cursor:pointer;display:inline-flex;align-items:center;gap:4px;transition:opacity .15s;}
+.btn:hover{opacity:.85;}
+.btn:disabled{opacity:.45;cursor:default;}
+.btn-ghost{background:transparent;border:1px solid rgba(55,65,81,.9);color:var(--muted);}
+.btn-ghost:hover{border-color:var(--accent);color:var(--accent);}
+.btn-danger{background:rgba(239,68,68,.08);color:var(--danger);border:1px solid rgba(248,113,113,.4);}
+.btn-warn{background:rgba(245,158,11,.08);color:var(--warn);border:1px solid rgba(245,158,11,.4);}
+.btn-sm{padding:3px 9px;font-size:.68rem;}
+
+/* ── Tables ── */
+.tbl-wrap{overflow-x:auto;margin:4px 0;}
+table{width:100%;border-collapse:collapse;font-size:.73rem;}
+thead{background:rgba(15,23,42,.9);}
+th,td{padding:6px 7px;text-align:left;border-bottom:1px solid rgba(31,41,55,.95);white-space:nowrap;}
+th{font-weight:500;color:var(--muted);font-size:.68rem;}
+tbody tr:hover{background:rgba(15,23,42,.85);}
+
+/* ── Metrics ── */
+.metrics{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 10px;}
+.metric{flex:1 1 80px;min-width:80px;padding:8px;border-radius:10px;background:rgba(15,23,42,.9);border:1px solid rgba(31,41,55,.9);}
+.m-label{font-size:.68rem;color:var(--muted);}
+.m-val{font-size:.9rem;font-weight:600;margin-top:2px;}
+.bar{height:5px;border-radius:999px;background:rgba(31,41,55,.9);overflow:hidden;margin-top:5px;}
+.bar-inner{height:100%;background:linear-gradient(90deg,#22c55e,#eab308,#ef4444);transition:width .25s;}
+
+/* ── Forms ── */
+.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:8px;}
+@media(max-width:600px){.form-grid{grid-template-columns:1fr;}}
+.field{display:flex;flex-direction:column;gap:3px;}
+.field label{font-size:.68rem;color:var(--muted);}
+.field input,.field select,.field textarea{border-radius:8px;border:1px solid rgba(55,65,81,.9);padding:6px 10px;background:rgba(15,23,42,.9);color:var(--text);font-size:.73rem;outline:none;font-family:inherit;}
+.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--accent);box-shadow:0 0 0 1px rgba(59,130,246,.4);}
+.field-row{display:flex;gap:6px;align-items:center;}
+.field-row input{flex:1 1 0;}
+.hint{font-size:.68rem;color:var(--muted);}
+.form-actions{margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
+.statusbar{margin-top:6px;font-size:.68rem;color:var(--muted);display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;}
+
+/* ── Tabs ── */
+.tab-pane{display:none;}
+.tab-pane.active{display:block;}
+
+/* ── Collapsible ── */
+.collapsible.collapsed{display:none;}
+
+/* ── Login overlay ── */
+.overlay{position:fixed;inset:0;background:radial-gradient(circle at top,rgba(15,23,42,.98),rgba(15,23,42,.995));display:flex;align-items:center;justify-content:center;z-index:30;}
+.overlay-inner{width:100%;max-width:340px;margin:0 14px;padding:22px;border-radius:16px;background:linear-gradient(145deg,rgba(15,23,42,.98),rgba(2,6,23,1));border:1px solid rgba(55,65,81,.9);box-shadow:0 20px 60px rgba(15,23,42,1);}
+.ov-title{font-size:1.05rem;font-weight:700;margin-bottom:4px;}
+.ov-sub{font-size:.8rem;color:var(--muted);margin-bottom:14px;}
+.ov-field{width:100%;margin:6px 0;border-radius:8px;border:1px solid rgba(55,65,81,.9);background:rgba(15,23,42,.9);color:var(--text);padding:8px 12px;font-size:.82rem;outline:none;}
+.ov-field:focus{border-color:var(--accent);}
+.hidden{display:none!important;}
+.badge-on{color:var(--success);font-size:.68rem;}
+.badge-off{color:var(--muted);font-size:.68rem;}
+
+/* ── Xray log / config editor ── */
+.code-area{width:100%;background:rgba(15,23,42,.9);color:#e5e7eb;border:1px solid rgba(55,65,81,.9);border-radius:8px;padding:10px;font-family:monospace;font-size:.7rem;resize:vertical;outline:none;}
+pre.log-box{background:rgba(15,23,42,.9);color:#9ca3af;border:1px solid rgba(55,65,81,.9);border-radius:8px;padding:10px;font-size:.68rem;overflow-y:auto;max-height:180px;white-space:pre-wrap;word-break:break-all;}
+
+/* ── Mobile hardening ──
+ Keep the shell/cards inside the phone viewport and let wide tables scroll
+ inside their own card instead of pushing the whole panel sideways. */
+html,body{width:100%;max-width:100%;overflow-x:hidden;}
+.app,.shell,.tab-pane,.grid2,.grid2>*,.card,.tbl-wrap,.form-grid,.field,.metric{min-width:0;max-width:100%;}
+.card-title{flex-wrap:wrap;min-width:0;}
+.card-hdr>*{min-width:0;}
+.field input,.field select,.field textarea,.ov-field,.code-area{max-width:100%;min-width:0;}
+
+@media(max-width:640px){
+ .app{padding:8px;}
+ .shell{width:100%;padding:12px;border-radius:16px;overflow:hidden;}
+ header{align-items:flex-start;}
+ header nav{order:2;width:100%;flex-wrap:nowrap;overflow-x:auto;overflow-y:hidden;padding-bottom:4px;-webkit-overflow-scrolling:touch;scrollbar-width:thin;}
+ .tab-btn{flex:0 0 auto;padding:5px 10px;}
+ .hright{order:3;width:100%;flex-wrap:wrap;}
+ .grid2{display:block;}
+ .grid2>.card+.card,.grid2>div+.card,.grid2>.card+div,.grid2>div+div{margin-top:12px;}
+ .card{width:100%;padding:12px;overflow:hidden;}
+ .card-hdr{align-items:flex-start;}
+ .card-hdr>div[style*="display:flex"],.card-hdr .form-actions{flex-wrap:wrap;}
+ .metrics{display:grid;grid-template-columns:1fr;gap:8px;}
+ .metric{width:100%;}
+ .tbl-wrap{width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:4px;}
+ .tbl-wrap table{width:max-content;min-width:100%;}
+ table{font-size:.7rem;}
+ th,td{padding:6px 8px;}
+ .statusbar{display:block;}
+ .statusbar>span{display:block;margin-top:3px;}
+ .field-row{flex-wrap:wrap;}
+ .field-row input{flex:1 1 160px;min-width:0;}
+ .field-row .btn{flex:0 0 auto;}
+ /* Override inline 2-column grids in Xray/TLS forms on phones. */
+ .form-grid[style*="grid-template-columns"],
+ #wzVlessFields,#wzSSFields,#wzCertSrcFile,#wzCertSrcPaste>div,
+ #tlsLEFields,#tlsCustomFields,#tlsPasteFields .form-grid{grid-template-columns:1fr!important;}
+}
+
+/* ──────────────────────────────────────────────────────────────
+ DragonCore professional panel shell
+ ────────────────────────────────────────────────────────────── */
+:root{
+ --bg:#121212;
+ --bg2:#1b1b1c;
+ --panel:#222223;
+ --panel2:#282829;
+ --card:#242425;
+ --card2:#2a2a2c;
+ --accent:#5a49f5;
+ --accent2:#ae2ff3;
+ --accent-soft:rgba(90,73,245,.18);
+ --border:rgba(255,255,255,.08);
+ --text:#f6f7fb;
+ --muted:#a2a2aa;
+ --danger:#ff7070;
+ --success:#36d37a;
+ --warn:#ffbe4c;
+ --shadow:0 18px 50px rgba(0,0,0,.35);
+}
+html,body{background:var(--bg);color:var(--text);}
+body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;}
+body.light-mode{
+ --bg:#f4f6fb;--bg2:#fff;--panel:#fff;--panel2:#f7f8fc;--card:#fff;--card2:#f8f9fd;
+ --border:rgba(15,23,42,.10);--text:#172033;--muted:#667085;--shadow:0 18px 45px rgba(31,41,55,.12);
+}
+.app{padding:0;background:linear-gradient(180deg,var(--bg),#101010);min-height:100vh;}
+.shell{max-width:none;margin:0;background:transparent;border:0;border-radius:0;box-shadow:none;padding:0;min-height:100vh;}
+.panel-layout{display:grid;grid-template-columns:280px minmax(0,1fr);min-height:100vh;background:var(--bg);}
+.sidebar{position:sticky;top:0;height:100vh;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:20;box-shadow:18px 0 45px rgba(0,0,0,.18);}
+.brand-block{height:112px;display:flex;align-items:center;gap:14px;padding:22px 22px;border-bottom:1px solid var(--border);}
+.brand-mark{width:54px;height:54px;border-radius:18px;display:grid;place-items:center;background:linear-gradient(135deg,rgba(90,73,245,.18),rgba(174,47,243,.18));font-size:2rem;filter:drop-shadow(0 10px 18px rgba(0,0,0,.35));}
+.brand-copy{display:flex;flex-direction:column;gap:2px;}
+.brand-copy strong{font-size:1.05rem;letter-spacing:.02em;}
+.brand-copy span{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.18em;}
+.side-nav{display:flex;flex-direction:column;gap:5px;padding:20px 16px;overflow-y:auto;flex:1;}
+.nav-group-label{font-size:.68rem;color:var(--muted);text-transform:uppercase;letter-spacing:.16em;margin:14px 12px 4px;}
+.side-nav .tab-btn{width:100%;justify-content:flex-start;text-align:left;border-radius:13px;padding:12px 13px;color:#d9d9df;border:1px solid transparent;background:transparent;font-size:.92rem;font-weight:650;display:flex;align-items:center;gap:12px;}
+.side-nav .tab-btn:hover{background:rgba(255,255,255,.04);border-color:var(--border);color:#fff;}
+.side-nav .tab-btn.active{background:linear-gradient(135deg,rgba(90,73,245,.72),rgba(59,54,136,.78));color:#fff;border-color:rgba(255,255,255,.08);box-shadow:0 12px 28px rgba(90,73,245,.22);}
+.nav-icon{width:25px;text-align:center;font-size:1.05rem;opacity:.94;}
+.sidebar-foot{border-top:1px solid var(--border);padding:16px 18px;display:flex;align-items:center;gap:13px;background:rgba(0,0,0,.08);}
+.avatar-dragon{width:42px;height:42px;border-radius:14px;background:#111;display:grid;place-items:center;font-size:1.45rem;}
+.sidebar-foot strong{display:block;font-size:.93rem;}
+.sidebar-foot span{display:block;color:var(--muted);font-size:.78rem;margin-top:2px;text-transform:capitalize;}
+.workspace{min-width:0;display:flex;flex-direction:column;background:radial-gradient(circle at 35% -10%,rgba(90,73,245,.12),transparent 32%),var(--bg);}
+.topbar{height:84px;display:flex;align-items:center;justify-content:space-between;gap:18px;padding:0 26px;border-bottom:1px solid var(--border);background:rgba(31,31,32,.86);backdrop-filter:blur(18px);position:sticky;top:0;z-index:15;margin:0;}
+body.light-mode .topbar{background:rgba(255,255,255,.82);}
+.topbar-left,.topbar-actions{display:flex;align-items:center;gap:14px;min-width:0;}
+.topbar-title{display:flex;flex-direction:column;min-width:0;}
+.topbar-title span{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.18em;}
+.topbar-title strong{font-size:1.08rem;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.icon-btn,.toolbar-pill{border:0;background:rgba(255,255,255,.05);color:var(--text);border:1px solid var(--border);height:42px;min-width:42px;padding:0 12px;border-radius:14px;display:inline-flex;align-items:center;justify-content:center;font-weight:700;cursor:pointer;}
+.icon-btn:hover,.toolbar-pill:hover{background:rgba(255,255,255,.09);}
+.toolbar-pill{gap:8px;font-size:.85rem;}
+.user-pill{height:42px;border:1px solid var(--border);background:rgba(255,255,255,.04);border-radius:999px;display:flex;align-items:center;gap:8px;padding:0 12px;}
+.user-pill strong{font-size:.88rem;max-width:120px;overflow:hidden;text-overflow:ellipsis;}
+.workspace-main{padding:28px;min-width:0;}
+.tab-pane{animation:fadeIn .18s ease-out;}
+@keyframes fadeIn{from{opacity:.4;transform:translateY(4px)}to{opacity:1;transform:none}}
+.card{background:linear-gradient(180deg,var(--card2),var(--card));border:1px solid var(--border);border-radius:18px;padding:18px;box-shadow:var(--shadow);}
+.card-title{font-size:1rem;font-weight:760;}
+.card-hdr{margin-bottom:14px;}
+.grid2{gap:18px;}
+.card+.card{margin-top:18px;}
+.chip{border-color:var(--border);background:rgba(255,255,255,.05);padding:4px 9px;font-size:.7rem;}
+.chip.green{background:rgba(54,211,122,.12);}
+.chip.warn{background:rgba(255,190,76,.12);}
+.chip.red{background:rgba(255,112,112,.12);}
+.btn{border-radius:12px;padding:9px 14px;font-size:.82rem;background:linear-gradient(135deg,var(--accent),#3b82f6);font-weight:750;box-shadow:0 10px 24px rgba(90,73,245,.22);}
+.btn-sm{padding:7px 11px;font-size:.76rem;}
+.btn-ghost{box-shadow:none;background:rgba(255,255,255,.04);border:1px solid var(--border);color:var(--text);}
+.btn-ghost:hover{border-color:rgba(90,73,245,.55);color:#fff;background:rgba(90,73,245,.12);}
+.btn-danger{box-shadow:none;background:rgba(255,112,112,.12);border:1px solid rgba(255,112,112,.30);color:var(--danger);}
+.btn-warn{box-shadow:none;background:rgba(255,190,76,.12);border:1px solid rgba(255,190,76,.30);color:var(--warn);}
+.btn-light{background:#fff;color:#4e3dde;box-shadow:none;}
+.btn-soft{background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.2);box-shadow:none;color:#fff;}
+.metrics{gap:12px;}
+.metric{background:rgba(255,255,255,.045);border:1px solid var(--border);border-radius:15px;padding:14px;}
+.m-label{font-size:.76rem;text-transform:uppercase;letter-spacing:.08em;}
+.m-val{font-size:1.2rem;margin-top:5px;}
+.tbl-wrap{border:1px solid var(--border);border-radius:15px;overflow:auto;background:rgba(255,255,255,.025);}
+table{font-size:.82rem;}
+thead{background:rgba(255,255,255,.04);}
+th,td{padding:11px 12px;border-bottom:1px solid var(--border);}
+th{font-size:.72rem;text-transform:uppercase;letter-spacing:.07em;}
+tbody tr:hover{background:rgba(90,73,245,.08);}
+.field label{font-size:.76rem;font-weight:680;}
+.field input,.field select,.field textarea,.ov-field,.code-area{border-radius:12px;border:1px solid var(--border);background:rgba(255,255,255,.045);color:var(--text);padding:10px 12px;font-size:.84rem;}
+.field input:focus,.field select:focus,.field textarea:focus,.ov-field:focus,.code-area:focus{border-color:rgba(90,73,245,.70);box-shadow:0 0 0 3px rgba(90,73,245,.15);}
+.form-grid{gap:12px;}
+.statusbar,.hint{font-size:.76rem;}
+.overlay{background:radial-gradient(circle at top,rgba(90,73,245,.34),rgba(18,18,18,.96) 42%,#0b0b0c);}
+.overlay-inner{border-radius:24px;background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--border);box-shadow:0 30px 80px rgba(0,0,0,.5);padding:28px;}
+.ov-title{font-size:1.4rem;}
+.welcome-card{background:linear-gradient(135deg,#5547f4,#af2ff4);border-radius:22px;padding:30px 28px;display:flex;justify-content:space-between;gap:20px;align-items:center;box-shadow:0 20px 55px rgba(90,73,245,.28);margin-bottom:22px;}
+.welcome-kicker{font-size:.78rem;text-transform:uppercase;letter-spacing:.18em;color:rgba(255,255,255,.75);margin-bottom:8px;}
+.welcome-card h1{font-size:1.9rem;line-height:1.08;margin:0 0 7px;}
+.welcome-card p{color:rgba(255,255,255,.82);font-size:1.02rem;margin:0;}
+.welcome-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end;}
+.dash-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px;margin-bottom:20px;}
+.dash-card{position:relative;overflow:hidden;min-height:156px;background:linear-gradient(180deg,var(--card2),var(--card));border:1px solid var(--border);border-left:6px solid rgba(90,73,245,.8);border-radius:20px;padding:22px;display:flex;justify-content:space-between;align-items:center;box-shadow:var(--shadow);}
+.dash-card:after{content:"";position:absolute;right:-45px;top:-45px;width:140px;height:140px;border-radius:50%;background:rgba(255,255,255,.035);}
+.dash-card-main{display:flex;flex-direction:column;gap:8px;z-index:1;}
+.dash-label{font-size:.82rem;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);}
+.dash-card strong{font-size:2.45rem;line-height:1;}
+.dash-card small{color:var(--muted);font-size:.88rem;}
+.dash-icon{width:68px;height:68px;border-radius:999px;display:grid;place-items:center;font-size:1.7rem;color:#fff;z-index:1;background:rgba(90,73,245,.38);}
+.accent-green{border-left-color:#25c266}.accent-green .dash-icon{background:rgba(37,194,102,.38)}
+.accent-purple{border-left-color:#af2ff4}.accent-purple .dash-icon{background:rgba(175,47,244,.38)}
+.accent-orange{border-left-color:#ff9f43}.accent-orange .dash-icon{background:rgba(255,159,67,.38)}
+.dashboard-lower{align-items:start;}
+.quick-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;}
+.quick-action{border:1px solid var(--border);background:rgba(255,255,255,.04);border-radius:16px;color:var(--text);padding:16px;text-align:left;cursor:pointer;display:flex;flex-direction:column;gap:5px;}
+.quick-action:hover{background:rgba(90,73,245,.12);border-color:rgba(90,73,245,.45);}
+.quick-action strong{font-size:.92rem;}.quick-action span{font-size:.78rem;color:var(--muted);}
+.quota-meter{height:12px;background:rgba(255,255,255,.06);border-radius:99px;overflow:hidden;border:1px solid var(--border);}
+.quota-meter span{display:block;height:100%;width:0%;border-radius:inherit;background:linear-gradient(90deg,#36d37a,#ffbe4c,#ff7070);transition:width .25s;}
+.badge-on{color:var(--success);font-weight:750}.badge-off{color:var(--muted);font-weight:700}
+pre.log-box{background:#121214;border-color:var(--border);border-radius:15px;}
+.drawer-backdrop{display:none;}
+@media(max-width:1180px){.dash-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.panel-layout{grid-template-columns:250px minmax(0,1fr);}}
+@media(max-width:820px){
+ .panel-layout{grid-template-columns:1fr;}
+ .sidebar{position:fixed;left:0;top:0;bottom:0;width:min(82vw,300px);height:100vh;transform:translateX(-105%);transition:transform .22s ease;}
+ body.sidebar-open .sidebar{transform:translateX(0);}
+ .drawer-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.58);z-index:18;backdrop-filter:blur(4px);}
+ body.sidebar-open .drawer-backdrop{display:block;}
+ .topbar{height:auto;min-height:76px;padding:12px 14px;align-items:flex-start;}
+ .topbar-actions{gap:8px;justify-content:flex-end;flex-wrap:wrap;}
+ .toolbar-pill,.topbar-actions .icon-btn,.user-pill{display:none;}
+ .workspace-main{padding:18px 14px 28px;}
+ .welcome-card{padding:28px 22px;align-items:flex-start;flex-direction:column;}
+ .welcome-card h1{font-size:1.6rem;}
+ .dash-grid{grid-template-columns:1fr;gap:14px;}
+ .dash-card{min-height:148px;}
+ .grid2{display:block;}
+ .grid2>div+div,.grid2>.card+div,.grid2>div+.card,.grid2>.card+.card{margin-top:16px;}
+ .quick-actions{grid-template-columns:1fr;}
+ .card{padding:16px;border-radius:18px;}
+ .tbl-wrap table{min-width:780px;}
+ .form-grid,.form-grid[style*="grid-template-columns"],#wzVlessFields,#wzSSFields,#wzCertSrcFile,#wzCertSrcPaste>div,#tlsLEFields,#tlsCustomFields,#tlsPasteFields .form-grid{grid-template-columns:1fr!important;}
+}
+@media(max-width:520px){
+ .topbar-title strong{font-size:.98rem}.topbar-title span{font-size:.62rem;}
+ .welcome-card{border-radius:20px;margin-bottom:16px;}.dash-card{padding:20px;}
+ .dash-card strong{font-size:2.2rem}.dash-icon{width:62px;height:62px;}
+ th,td{padding:10px 10px;}
+}
+
+/* Better reseller counters and quota summaries */
+.reseller-helper-card{margin-bottom:14px;background:linear-gradient(135deg,rgba(90,73,245,.16),rgba(54,211,122,.06)),var(--card);}
+.mini-meter{width:100%;height:8px;background:rgba(255,255,255,.07);border:1px solid var(--border);border-radius:999px;overflow:hidden;margin-top:2px;max-width:210px;}
+.mini-meter span{display:block;height:100%;width:0%;border-radius:inherit;background:linear-gradient(90deg,#36d37a,#ffbe4c,#ff7070);transition:width .25s ease;}
+.mini-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:12px;}
+.mini-summary span{display:flex;flex-direction:column;gap:3px;background:rgba(255,255,255,.045);border:1px solid var(--border);border-radius:14px;padding:12px;min-width:0;}
+.mini-summary strong{font-size:1.05rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
+.mini-summary small{font-size:.72rem;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;}
+.quota-good{color:var(--success)!important}.quota-warn{color:var(--warn)!important}.quota-danger{color:var(--danger)!important}
+@media(max-width:620px){.mini-summary{grid-template-columns:1fr}.mini-meter{max-width:none}}
+.table-meter{height:6px;min-width:130px;max-width:220px;background:rgba(255,255,255,.07);border:1px solid var(--border);border-radius:999px;overflow:hidden;margin-top:6px;}
+.table-meter span{display:block;height:100%;background:linear-gradient(90deg,#36d37a,#ffbe4c,#ff7070);border-radius:inherit;}
diff --git a/admin/assets/app.js b/admin/assets/app.js
new file mode 100644
index 0000000..61b9836
--- /dev/null
+++ b/admin/assets/app.js
@@ -0,0 +1,1847 @@
+
+// ─── State ───────────────────────────────────────────────────────────────────
+let sessionToken = localStorage.getItem("SESSION_TOKEN") || "";
+let currentRole = "";
+let currentUser = "";
+let statsTimer = null, usersTimer = null, xrayTimer = null;
+let formCollapsed = true;
+let tlsForwardersState = [];
+let editingXrayClientId = null;
+let wzInbounds = [];
+let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null };
+
+// ─── DOM refs ─────────────────────────────────────────────────────────────────
+const loginOverlay = document.getElementById("loginOverlay");
+const loginUser = document.getElementById("loginUser");
+const loginPass = document.getElementById("loginPass");
+const loginBtn = document.getElementById("loginBtn");
+const loginErr = document.getElementById("loginErr");
+const mainApp = document.getElementById("mainApp");
+const meUsername = document.getElementById("meUsername");
+const roleChip = document.getElementById("roleChip");
+const logoutBtn = document.getElementById("logoutBtn");
+const menuToggle = document.getElementById("menuToggle");
+const drawerBackdrop = document.getElementById("drawerBackdrop");
+const themeToggle = document.getElementById("themeToggle");
+const pageTitle = document.getElementById("pageTitle");
+const pageEyebrow = document.getElementById("pageEyebrow");
+const sidebarUsername = document.getElementById("sidebarUsername");
+const sidebarRole = document.getElementById("sidebarRole");
+const dashTotalUsers = document.getElementById("dashTotalUsers");
+const dashActiveUsers = document.getElementById("dashActiveUsers");
+const dashExpiredUsers = document.getElementById("dashExpiredUsers");
+const dashAccountBreakdown = document.getElementById("dashAccountBreakdown");
+const dashConnections = document.getElementById("dashConnections");
+const dashConnectionsText = document.getElementById("dashConnectionsText");
+const dashServers = document.getElementById("dashServers");
+const dashServerStatus = document.getElementById("dashServerStatus");
+const dashXrayClients = document.getElementById("dashXrayClients");
+const dashXrayStatus = document.getElementById("dashXrayStatus");
+const dashQuotaChip = document.getElementById("dashQuotaChip");
+const dashQuotaBar = document.getElementById("dashQuotaBar");
+const dashQuotaText = document.getElementById("dashQuotaText");
+const dashQuotaBreakdown = document.getElementById("dashQuotaBreakdown");
+const dashQuotaRemaining = document.getElementById("dashQuotaRemaining");
+const dashQuotaSummaryText = document.getElementById("dashQuotaSummaryText");
+const dashQuotaMiniBar = document.getElementById("dashQuotaMiniBar");
+const xrayResellerQuotaUsed = document.getElementById("xrayResellerQuotaUsed");
+const xrayResellerQuotaRemaining = document.getElementById("xrayResellerQuotaRemaining");
+const xrayResellerQuotaMix = document.getElementById("xrayResellerQuotaMix");
+const dashboardQuotaCard = document.getElementById("dashboardQuotaCard");
+
+// Users
+const usersBody = document.getElementById("usersBody");
+const userCountChip = document.getElementById("userCountChip");
+const userStatus = document.getElementById("userStatus");
+const lastReload = document.getElementById("lastReload");
+const ownerColHead = document.getElementById("ownerColHead");
+const resellerInfoCard = document.getElementById("resellerInfoCard");
+const rUsedMax = document.getElementById("rUsedMax");
+const rExpiry = document.getElementById("rExpiry");
+const rStatus = document.getElementById("rStatus");
+
+// User form
+const userForm = document.getElementById("userForm");
+const userFormWrap = document.getElementById("userFormWrap");
+const toggleFormBtn = document.getElementById("toggleFormBtn");
+const cancelUserBtn = document.getElementById("cancelUserBtn");
+const newUserBtn = document.getElementById("newUserBtn");
+const saveUserBtn = document.getElementById("saveUserBtn");
+const fUsername = document.getElementById("fUsername");
+const fPassword = document.getElementById("fPassword");
+const fTotpSecret = document.getElementById("fTotpSecret");
+const fTotpPeriod = document.getElementById("fTotpPeriod");
+const fTotpWindow = document.getElementById("fTotpWindow");
+const fTotpDigits = document.getElementById("fTotpDigits");
+const fAllowStatic = document.getElementById("fAllowStatic");
+const fMaxConn = document.getElementById("fMaxConn");
+const fExpires = document.getElementById("fExpires");
+const fUp = document.getElementById("fUp");
+const fDown = document.getElementById("fDown");
+
+// Xray
+const xrayChip = document.getElementById("xrayChip");
+const xRunning = document.getElementById("xRunning");
+const xPID = document.getElementById("xPID");
+const xUptime = document.getElementById("xUptime");
+const xStatus = document.getElementById("xStatus");
+const xCfgEditor = document.getElementById("xCfgEditor");
+const xCfgStatus = document.getElementById("xCfgStatus");
+const xLogsBox = document.getElementById("xLogsBox");
+const inboundsContainer = document.getElementById("inboundsContainer");
+
+// Resellers
+const resellersBody = document.getElementById("resellersBody");
+const resellerCountChip = document.getElementById("resellerCountChip");
+const resellerStatus = document.getElementById("resellerStatus");
+const resellerFormTitle = document.getElementById("resellerFormTitle");
+const resellerForm = document.getElementById("resellerForm");
+const rUsername = document.getElementById("rUsername");
+const rPassword = document.getElementById("rPassword");
+const rMaxUsers = document.getElementById("rMaxUsers");
+const rExpires = document.getElementById("rExpires");
+const rActive = document.getElementById("rActive");
+
+// Stats
+const cpuVal = document.getElementById("cpuVal");
+const cpuBar = document.getElementById("cpuBar");
+const memVal = document.getElementById("memVal");
+const memBar = document.getElementById("memBar");
+const memDetail = document.getElementById("memDetail");
+const ifaceBody = document.getElementById("ifaceBody");
+const ifaceSummary = document.getElementById("ifaceSummary");
+const statsUpdated = document.getElementById("statsUpdated");
+const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn");
+
+// VnStat
+const vnstatDailyBody = document.getElementById("vnstatDailyBody");
+const vnstatMonthlyBody = document.getElementById("vnstatMonthlyBody");
+const vnstatStatus = document.getElementById("vnstatStatus");
+const vnTodayTotal = document.getElementById("vnTodayTotal");
+const vnMonthTotal = document.getElementById("vnMonthTotal");
+const vnIfaceCount = document.getElementById("vnIfaceCount");
+const reloadVnstatBtn = document.getElementById("reloadVnstatBtn");
+const resetVnstatBtn = document.getElementById("resetVnstatBtn");
+
+// ─── API helper ───────────────────────────────────────────────────────────────
+async function api(path, opts = {}) {
+ const o = Object.assign({ headers: {} }, opts);
+ o.headers = Object.assign({}, o.headers, {
+ "Content-Type": "application/json",
+ "X-Session-Token": sessionToken,
+ });
+ const res = await fetch(path, o);
+ if (res.status === 401 || res.status === 403) throw new Error("auth");
+ return res;
+}
+
+// ─── Formatters ──────────────────────────────────────────────────────────────
+const fmtPct = n => (n == null || isNaN(n)) ? "--%" : n.toFixed(1)+"%";
+const fmtMbps = n => (n == null || isNaN(n)) ? "--" : n.toFixed(2);
+function fmtBytes(n) {
+ if (!Number.isFinite(n)) return "--";
+ if (n<1024) return n+" B";
+ const k=n/1024; if(k<1024) return k.toFixed(1)+" KiB";
+ const m=k/1024; if(m<1024) return m.toFixed(1)+" MiB";
+ return (m/1024).toFixed(1)+" GiB";
+}
+function localDateKey(d = new Date()) {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, "0");
+ const day = String(d.getDate()).padStart(2, "0");
+ return `${y}-${m}-${day}`;
+}
+function fmtDate(iso) {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return "—";
+ return d.toLocaleDateString()+" "+d.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
+}
+function isoFromLocal(v) {
+ if (!v) return "";
+ const d = new Date(v);
+ return isNaN(d.getTime()) ? "" : d.toISOString();
+}
+function localFromISO(iso) {
+ if (!iso) return "";
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return "";
+ const pad = n => String(n).padStart(2,"0");
+ return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+function genBase32(len=20) {
+ const alpha="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+ const bytes=new Uint8Array(len);
+ crypto.getRandomValues(bytes);
+ let bits=0,val=0,out="";
+ for(const b of bytes){val=(val<<8)|b;bits+=8;while(bits>=5){out+=alpha[(val>>>(bits-5))&31];bits-=5;}}
+ if(bits>0) out+=alpha[(val<<(5-bits))&31];
+ return out;
+}
+function genUUID() {
+ return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
+ (c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16));
+}
+
+// ─── Navigation / shell ──────────────────────────────────────────────────────
+const tabTitles = {
+ dashboard: ["Painel", "Visão geral"],
+ ssh: ["Contas", "SSH / SlowDNS"],
+ xray: ["Contas", "Xray Users"],
+ resellers: ["Administração", "Revendedores"],
+ stats: ["Servidor", "Monitoramento"],
+ vnstat: ["Tráfego", "VnStat"],
+ logs: ["Sistema", "Logs"],
+ server: ["Sistema", "Configurações"],
+};
+
+function selectTab(tab) {
+ const pane = document.getElementById("tab-" + tab);
+ const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`);
+ if (!pane || !btn) return;
+ document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
+ document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
+ btn.classList.add("active");
+ pane.classList.add("active");
+ const [eyebrow, title] = tabTitles[tab] || ["Painel", tab];
+ if (pageEyebrow) pageEyebrow.textContent = eyebrow;
+ if (pageTitle) pageTitle.textContent = title;
+ document.body.classList.remove("sidebar-open");
+
+ if (tab === "dashboard") refreshDashboard();
+ if (tab === "xray") {
+ loadXrayStatus();
+ loadInbounds();
+ if (currentRole === "superadmin") loadWizardFromConfig();
+ }
+ if (tab === "stats" && currentRole === "superadmin") loadStats();
+ if (tab === "resellers" && currentRole === "superadmin") loadResellers();
+}
+
+document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab)));
+menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
+drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
+themeToggle?.addEventListener("click", () => document.body.classList.toggle("light-mode"));
+document.querySelectorAll(".quick-action[data-jump]").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.jump)));
+document.getElementById("quickCreateUserBtn")?.addEventListener("click", () => { selectTab("ssh"); setFormCollapsed(false); fUsername?.focus(); });
+document.getElementById("quickOpenXrayBtn")?.addEventListener("click", () => selectTab("xray"));
+
+// ─── Login / Logout ───────────────────────────────────────────────────────────
+loginBtn.addEventListener("click", doLogin);
+loginPass.addEventListener("keydown", e => { if (e.key==="Enter") doLogin(); });
+logoutBtn.addEventListener("click", async () => {
+ try { await api("/api/auth/logout", { method: "POST" }); } catch {}
+ sessionToken = "";
+ localStorage.removeItem("SESSION_TOKEN");
+ clearTimers();
+ mainApp.classList.add("hidden");
+ loginOverlay.classList.remove("hidden");
+ loginErr.textContent = "";
+ loginUser.value = loginPass.value = "";
+});
+
+async function doLogin() {
+ loginErr.textContent = "";
+ loginBtn.disabled = true;
+ try {
+ const res = await fetch("/api/auth/login", {
+ method: "POST",
+ headers: {"Content-Type":"application/json"},
+ body: JSON.stringify({ username: loginUser.value.trim(), password: loginPass.value }),
+ });
+ if (!res.ok) {
+ loginErr.textContent = res.status === 401 ? "Invalid credentials." :
+ res.status === 403 ? "Account suspended or expired." :
+ "Login failed.";
+ return;
+ }
+ const data = await res.json();
+ sessionToken = data.token;
+ currentRole = data.role;
+ currentUser = data.username;
+ localStorage.setItem("SESSION_TOKEN", sessionToken);
+ loginOverlay.classList.add("hidden");
+ mainApp.classList.remove("hidden");
+ initAfterLogin();
+ } catch (e) {
+ loginErr.textContent = "Network error.";
+ } finally {
+ loginBtn.disabled = false;
+ }
+}
+
+// ─── Init after login ─────────────────────────────────────────────────────────
+function clearTimers() {
+ [statsTimer, usersTimer, xrayTimer].forEach(t => t && clearInterval(t));
+ statsTimer = usersTimer = xrayTimer = null;
+}
+
+function initAfterLogin() {
+ meUsername.textContent = currentUser;
+ if (sidebarUsername) sidebarUsername.textContent = currentUser;
+ if (sidebarRole) sidebarRole.textContent = currentRole === "superadmin" ? "Super Admin" : "Revendedor";
+ roleChip.innerHTML = currentRole === "superadmin"
+ ? `superadmin`
+ : `reseller`;
+
+ document.querySelectorAll(".superadmin-only").forEach(el => {
+ el.classList.toggle("hidden", currentRole !== "superadmin");
+ });
+ document.querySelectorAll(".reseller-only").forEach(el => {
+ el.classList.toggle("hidden", currentRole !== "reseller");
+ });
+ document.querySelectorAll(".xray-admin-only").forEach(el => {
+ el.classList.toggle("hidden", currentRole !== "superadmin");
+ });
+
+ resellerInfoCard.classList.toggle("hidden", currentRole !== "reseller");
+ dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller");
+ updateRoleWelcome();
+
+ selectTab("dashboard");
+
+ if (currentRole === "superadmin") {
+ loadStats();
+ statsTimer = setInterval(loadStats, 2000);
+ } else {
+ loadMe();
+ }
+ xrayTimer = setInterval(loadXrayStatus, 7000);
+
+ loadUsers();
+ loadInbounds();
+ usersTimer = setInterval(() => loadUsersSilent(), 3000);
+}
+
+function updateRoleWelcome() {
+ const title = document.getElementById("welcomeTitle");
+ const sub = document.getElementById("welcomeSub");
+ const kicker = document.getElementById("welcomeKicker");
+ if (!title || !sub || !kicker) return;
+ const name = currentUser || "admin";
+ title.textContent = `Bem-vindo de volta, ${name} 👋`;
+ if (currentRole === "reseller") {
+ kicker.textContent = "Painel do revendedor";
+ sub.textContent = "Crie contas SSH e Xray com cota única, sem precisar tocar em configurações técnicas.";
+ } else {
+ kicker.textContent = "Painel operacional";
+ sub.textContent = "Gerencie SSH, Xray, revendedores e servidor em poucos cliques.";
+ }
+}
+
+// ─── Me (reseller info) ───────────────────────────────────────────────────────
+async function loadMe() {
+ try {
+ const res = await api("/api/auth/me");
+ const d = await res.json();
+ dashboardCache.me = d;
+ const used = d.used_users ?? 0;
+ const max = d.max_users || 0;
+ rUsedMax.textContent = used + " / " + (max || "∞");
+ rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : "Sem vencimento";
+ rStatus.textContent = d.is_active ? "Ativo" : "Suspenso";
+ rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)";
+ updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0);
+ renderDashboardCounters();
+ } catch {}
+}
+
+function quotaToneClass(pct, remaining) {
+ if (remaining === 0 || pct >= 90) return "quota-danger";
+ if (pct >= 75) return "quota-warn";
+ return "quota-good";
+}
+
+function setQuotaTone(el, tone) {
+ if (!el) return;
+ el.classList.remove("quota-good", "quota-warn", "quota-danger");
+ el.classList.add(tone);
+}
+
+function updateQuotaCard(used, max, sshUsed = 0, xrayUsed = 0) {
+ if (!dashQuotaText) return;
+ const unlimited = !max;
+ const remaining = unlimited ? "∞" : Math.max(0, max - used);
+ const pct = unlimited ? 0 : Math.min(100, Math.round((used / max) * 100));
+ const tone = quotaToneClass(pct, remaining === "∞" ? 999999 : remaining);
+ const labelMax = unlimited ? "∞" : max;
+
+ dashQuotaChip.textContent = `${used} / ${labelMax}`;
+ dashQuotaChip.className = `chip ${pct >= 90 ? "red" : pct >= 75 ? "warn" : "green"}`;
+ dashQuotaText.textContent = unlimited
+ ? "Sem limite definido pelo admin"
+ : `${remaining} contas disponíveis · ${pct}% usado`;
+ dashQuotaBreakdown.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`;
+ dashQuotaBar.style.width = `${pct}%`;
+
+ if (dashQuotaRemaining) {
+ dashQuotaRemaining.textContent = String(remaining);
+ setQuotaTone(dashQuotaRemaining, tone);
+ }
+ if (dashQuotaSummaryText) {
+ dashQuotaSummaryText.textContent = unlimited
+ ? `${used} usadas · sem limite`
+ : `${used}/${max} usadas · ${pct}% do plano`;
+ }
+ if (dashQuotaMiniBar) dashQuotaMiniBar.style.width = `${pct}%`;
+ if (xrayResellerQuotaUsed) xrayResellerQuotaUsed.textContent = `${used}/${labelMax}`;
+ if (xrayResellerQuotaRemaining) {
+ xrayResellerQuotaRemaining.textContent = String(remaining);
+ setQuotaTone(xrayResellerQuotaRemaining, tone);
+ }
+ if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`;
+}
+
+function flattenXrayClients(inbounds = []) {
+ return inbounds.flatMap(ib => (ib.clients || []).map(c => Object.assign({ inbound_tag: ib.tag }, c)));
+}
+
+function isExpiredDate(value) {
+ return !!value && new Date(value) < new Date();
+}
+
+function formatBytes(bytes) {
+ const n = Number(bytes || 0);
+ if (!n) return "0 B";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let v = n, i = 0;
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
+ return `${v >= 10 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
+}
+
+function formatLastActive(value) {
+ if (!value) return "--";
+ const diff = Math.max(0, Date.now() - new Date(value).getTime());
+ const sec = Math.floor(diff / 1000);
+ if (sec < 60) return `${sec}s atrás`;
+ const min = Math.floor(sec / 60);
+ if (min < 60) return `${min}m atrás`;
+ const hrs = Math.floor(min / 60);
+ if (hrs < 24) return `${hrs}h atrás`;
+ return new Date(value).toLocaleString();
+}
+
+function renderDashboardCounters() {
+ if (!dashTotalUsers) return;
+ const sshUsers = dashboardCache.sshUsers || [];
+ const xrayClients = flattenXrayClients(dashboardCache.xrayInbounds || []);
+ const sshExpired = sshUsers.filter(u => isExpiredDate(u.expires_at)).length;
+ const xrayExpired = xrayClients.filter(c => c.expired || isExpiredDate(c.expires_at)).length;
+ const sshActive = Math.max(0, sshUsers.length - sshExpired);
+ const xrayActive = Math.max(0, xrayClients.length - xrayExpired);
+ const total = sshUsers.length + xrayClients.length;
+ const active = sshActive + xrayActive;
+ const expired = sshExpired + xrayExpired;
+ const sshConns = sshUsers.reduce((sum, u) => sum + Number(u.active_conns || 0), 0);
+ const xrayOnline = xrayClients.filter(c => !!c.online).length;
+ const liveTotal = sshConns + xrayOnline;
+
+ dashTotalUsers.textContent = total;
+ dashActiveUsers.textContent = active;
+ dashExpiredUsers.textContent = expired;
+ if (dashAccountBreakdown) dashAccountBreakdown.textContent = `SSH ${sshUsers.length} · Xray ${xrayClients.length}`;
+ dashConnections.textContent = liveTotal;
+ if (dashConnectionsText) dashConnectionsText.textContent = `${sshConns} SSH · ${xrayOnline} Xray online`;
+ if (dashXrayClients) dashXrayClients.textContent = xrayClients.length;
+ if (dashXrayStatus) {
+ const running = xrayChip?.textContent || "--";
+ dashXrayStatus.textContent = `${xrayOnline} online · ${xrayActive} ativos · ${xrayExpired} expirados · Core: ${running}`;
+ }
+
+ const me = dashboardCache.me;
+ if (currentRole === "reseller" && me) {
+ updateQuotaCard(me.used_users ?? total, me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
+ }
+}
+
+function updateDashboardFromUsers(users = []) {
+ dashboardCache.sshUsers = users || [];
+ renderDashboardCounters();
+}
+
+function updateDashboardXray(inbounds = []) {
+ dashboardCache.xrayInbounds = inbounds || [];
+ renderDashboardCounters();
+}
+
+function refreshDashboard() {
+ loadUsersSilent();
+ loadInbounds();
+ loadXrayStatus();
+ if (currentRole === "reseller") loadMe();
+}
+
+// ─── SSH Users ────────────────────────────────────────────────────────────────
+document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
+newUserBtn.addEventListener("click", () => {
+ setFormCollapsed(false);
+ userForm.reset();
+ fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
+ userStatus.textContent = "New user.";
+ fUsername.focus();
+});
+cancelUserBtn.addEventListener("click", () => setFormCollapsed(true));
+toggleFormBtn.addEventListener("click", () => setFormCollapsed(!formCollapsed));
+document.getElementById("genTotpBtn").addEventListener("click", () => {
+ fTotpSecret.value = genBase32();
+ if (!fTotpPeriod.value) fTotpPeriod.value = 60;
+ if (!fTotpWindow.value) fTotpWindow.value = 1;
+ if (!fTotpDigits.value) fTotpDigits.value = 6;
+ userStatus.textContent = "TOTP secret generated.";
+});
+document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; });
+
+function setFormCollapsed(v) {
+ formCollapsed = v;
+ userFormWrap.classList.toggle("collapsed", v);
+ toggleFormBtn.textContent = v ? "Show form" : "Hide form";
+}
+
+async function loadUsers() {
+ userStatus.textContent = "Loading…";
+ try {
+ const res = await api("/api/users");
+ const data = await res.json();
+ renderUsers(data || []);
+ userStatus.textContent = "Loaded.";
+ lastReload.textContent = "Last reload: " + new Date().toLocaleTimeString();
+ } catch (e) {
+ if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = "Error loading users."; }
+ }
+}
+async function loadUsersSilent() {
+ try {
+ const res = await api("/api/users");
+ const data = await res.json();
+ renderUsers(data || []);
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ }
+}
+
+function renderUsers(users) {
+ updateDashboardFromUsers(users);
+ const isSA = currentRole === "superadmin";
+ userCountChip.textContent = users.length;
+ if (isSA) ownerColHead.classList.remove("hidden");
+ usersBody.innerHTML = "";
+ let online = 0;
+ let expiredCount = 0;
+ users.forEach(u => {
+ const on = (u.active_conns || 0) > 0;
+ if (on) online++;
+ if (isExpiredDate(u.expires_at)) expiredCount++;
+ const tr = document.createElement("tr");
+ const cells = [
+ u.username,
+ on ? 'online' : 'idle',
+ u.totp_enabled ? (u.allow_static_password ? "TOTP+pw" : "TOTP") : "Password",
+ u.active_conns ?? 0,
+ u.max_connections || 0,
+ u.limit_mbps_up || 0,
+ u.limit_mbps_down || 0,
+ u.expires_at ? fmtDate(u.expires_at) : "—",
+ ];
+ if (isSA) cells.push(u.owner_username || "—");
+ cells.forEach((c, i) => {
+ const td = document.createElement("td");
+ if (i === 1) td.innerHTML = c; else td.textContent = c;
+ tr.appendChild(td);
+ });
+ const tdA = document.createElement("td");
+ const editBtn = Object.assign(document.createElement("button"), {
+ className:"btn btn-ghost btn-sm", textContent:"Edit",
+ onclick: () => fillUserForm(u),
+ });
+ const delBtn = Object.assign(document.createElement("button"), {
+ className:"btn btn-danger btn-sm", textContent:"Del",
+ style: "margin-left:4px;",
+ onclick: () => deleteUser(u.username),
+ });
+ tdA.append(editBtn, delBtn);
+ tr.appendChild(tdA);
+ usersBody.appendChild(tr);
+ });
+ const activeCount = Math.max(0, users.length - expiredCount);
+ userCountChip.textContent = `${users.length} total · ${activeCount} ativas · ${online} online`;
+}
+
+function fillUserForm(u) {
+ setFormCollapsed(false);
+ fUsername.value = u.username || "";
+ fPassword.value = "";
+ fTotpSecret.value = u.totp_secret || "";
+ fTotpPeriod.value = u.totp_period || 60;
+ fTotpWindow.value = u.totp_window ?? 1;
+ fTotpDigits.value = u.totp_digits || 6;
+ fAllowStatic.checked = !!u.allow_static_password;
+ fMaxConn.value = u.max_connections || "";
+ fUp.value = u.limit_mbps_up || "";
+ fDown.value = u.limit_mbps_down || "";
+ fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
+ userStatus.textContent = `Editing ${u.username}`;
+}
+
+userForm.addEventListener("submit", async e => {
+ e.preventDefault();
+ saveUserBtn.disabled = true;
+ userStatus.textContent = "Saving…";
+ const payload = {
+ username: fUsername.value.trim(),
+ password: fPassword.value || undefined,
+ totp_secret: fTotpSecret.value.trim(),
+ totp_period: parseInt(fTotpPeriod.value||"60",10),
+ totp_window: parseInt(fTotpWindow.value||"1",10),
+ totp_digits: parseInt(fTotpDigits.value||"6",10),
+ allow_static_password: !!fAllowStatic.checked,
+ max_connections: parseInt(fMaxConn.value||"0",10),
+ expires_at: isoFromLocal(fExpires.value),
+ limit_mbps_up: parseInt(fUp.value||"0",10),
+ limit_mbps_down: parseInt(fDown.value||"0",10),
+ };
+ try {
+ const res = await api("/api/users/create", { method:"POST", body: JSON.stringify(payload) });
+ if (!res.ok) throw new Error(await res.text());
+ userStatus.textContent = "Saved.";
+ fPassword.value = "";
+ loadUsers();
+ if (currentRole === "reseller") loadMe();
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else userStatus.textContent = "Error: " + e.message;
+ } finally {
+ saveUserBtn.disabled = false;
+ }
+});
+
+async function deleteUser(username) {
+ if (!confirm(`Delete user "${username}"?`)) return;
+ userStatus.textContent = `Deleting ${username}…`;
+ try {
+ const res = await api(`/api/users/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
+ if (!res.ok && res.status !== 204) throw new Error("delete failed");
+ userStatus.textContent = "Deleted.";
+ loadUsers();
+ if (currentRole === "reseller") loadMe();
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else userStatus.textContent = "Error deleting.";
+ }
+}
+
+// ─── Xray ─────────────────────────────────────────────────────────────────────
+document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("start"));
+document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop"));
+document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart"));
+document.getElementById("xRepairStatsBtn")?.addEventListener("click", repairXrayStats);
+document.getElementById("xRefreshBtn").addEventListener("click", loadXrayStatus);
+document.getElementById("xLoadInboundsBtn").addEventListener("click", loadInbounds);
+document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
+document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
+document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
+
+document.querySelector("[data-tab='xray']")?.addEventListener("click", () => {
+ loadXrayStatus();
+ loadInbounds();
+ if (currentRole === "superadmin") loadWizardFromConfig();
+});
+
+async function loadXrayStatus() {
+ try {
+ const res = await api("/api/xray/status");
+ const s = await res.json();
+ const run = !!s.running;
+ xrayChip.textContent = run ? "running" : (s.enabled ? "stopped" : "disabled");
+ xrayChip.className = "chip " + (run ? "green" : "red");
+ xRunning.textContent = run ? "Running" : "Stopped";
+ xRunning.style.color = run ? "var(--success)" : "var(--danger)";
+ xPID.textContent = s.pid || "--";
+ xUptime.textContent = s.uptime || "--";
+ const statsCfgEl = document.getElementById("xStatsConfig");
+ const repairBtn = document.getElementById("xRepairStatsBtn");
+ if (statsCfgEl) {
+ statsCfgEl.textContent = s.stats_configured ? "OK" : "Needs repair";
+ statsCfgEl.style.color = s.stats_configured ? "var(--success)" : "var(--warning)";
+ }
+ if (repairBtn) repairBtn.style.display = s.stats_configured ? "none" : "";
+ if (xOnlineUsers) xOnlineUsers.textContent = String(s.online_users ?? 0);
+ if (!s.stats_configured && xStatus) {
+ const missing = Array.isArray(s.stats_missing) && s.stats_missing.length ? ` Missing: ${s.stats_missing.join(", ")}.` : "";
+ xStatus.textContent = "Online counters need Stats API repair." + missing;
+ } else if (s.stats_error && xStatus) {
+ xStatus.textContent = "Online counters: " + s.stats_error;
+ } else if (xStatus) {
+ xStatus.textContent = s.api_server ? `Counters API ready at ${s.api_server}.` : "Counters API ready.";
+ }
+ if (dashServers) dashServers.textContent = s.enabled ? "1" : "0";
+ if (dashServerStatus) dashServerStatus.textContent = run ? "1 online" : (s.enabled ? "parado" : "desativado");
+ renderDashboardCounters();
+ if (s.error) xStatus.textContent = "Error: " + s.error;
+ } catch (e) { if (e.message==="auth") doAuthError(); }
+}
+
+async function repairXrayStats() {
+ const btn = document.getElementById("xRepairStatsBtn");
+ if (btn) btn.disabled = true;
+ xStatus.textContent = "Checking and repairing Xray counters API…";
+ try {
+ const res = await api("/api/xray/stats/repair", { method:"POST" });
+ if (!res.ok) throw new Error(await res.text());
+ const d = await res.json().catch(() => ({}));
+ xStatus.textContent = d.changed
+ ? (d.restarted ? "Counters API repaired and Xray restarted." : "Counters API repaired. Restart Xray to apply it.")
+ : "Counters API already looks correct.";
+ setTimeout(loadXrayStatus, 700);
+ setTimeout(loadInbounds, 1200);
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else xStatus.textContent = "Error repairing counters: "+e.message;
+ } finally {
+ if (btn) btn.disabled = false;
+ }
+}
+
+async function xrayCtrl(action) {
+ xStatus.textContent = action.charAt(0).toUpperCase()+action.slice(1)+"ing Xray…";
+ try {
+ const res = await api(`/api/xray/${action}`, { method:"POST" });
+ if (!res.ok) throw new Error(await res.text());
+ xStatus.textContent = "Xray "+action+" OK.";
+ setTimeout(loadXrayStatus, 700);
+ setTimeout(loadInbounds, 1200);
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else xStatus.textContent = "Error: "+e.message;
+ }
+}
+
+async function loadInbounds() {
+ inboundsContainer.innerHTML = '
Loading…
';
+ try {
+ const res = await api("/api/xray/inbounds");
+ const inbounds = await res.json();
+ renderInbounds(inbounds || []);
+ } catch (e) {
+ inboundsContainer.textContent = "Error loading inbounds.";
+ if (e.message==="auth") doAuthError();
+ }
+}
+
+function renderInbounds(inbounds) {
+ updateDashboardXray(inbounds);
+ if (!inbounds.length) {
+ inboundsContainer.innerHTML = 'No VLESS/VMess/Trojan inbounds found.
';
+ return;
+ }
+ inboundsContainer.innerHTML = "";
+ inbounds.forEach(ib => {
+ const section = document.createElement("div");
+ section.style = "margin-bottom:14px;";
+
+ const hdr = document.createElement("div");
+ hdr.className = "card-hdr";
+ hdr.style = "margin-bottom:6px;";
+ const clients = ib.clients || [];
+ const onlineCount = clients.filter(c => !!c.online).length;
+ hdr.innerHTML = `
+
+ ${ib.protocol}
+ ${ib.tag || "untagged"}
+ :${ib.port ?? "?"}
+ ${onlineCount} online
+
+ `;
+ section.appendChild(hdr);
+
+ // Add client mini-form (hidden by default)
+ const addForm = document.createElement("div");
+ addForm.id = `add-form-${ib.tag}`;
+ addForm.className = "hidden";
+ addForm.style = "background:rgba(15,23,42,.9);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;";
+ addForm.innerHTML = `
+
+
+
+
+
`;
+ section.appendChild(addForm);
+
+ // Clients table
+ const tblWrap = document.createElement("div");
+ tblWrap.className = "tbl-wrap";
+ if (!clients.length) {
+ tblWrap.innerHTML = 'No clients.
';
+ } else {
+ const tbl = document.createElement("table");
+ tbl.innerHTML = `| Name | UUID | Email | Expiry | Status | Online | Traffic | Max | Actions |
`;
+ const tbody = document.createElement("tbody");
+ clients.forEach(c => {
+ const exp = c.expires_at ? new Date(c.expires_at) : null;
+ const expStr = exp ? exp.toLocaleDateString() : "Unlimited";
+ const isExpired = !!c.expired;
+ const daysLeft = c.expiration_days;
+ let statusHtml;
+ if (isExpired) {
+ statusHtml = `Expired`;
+ } else if (daysLeft === -1 || !exp) {
+ statusHtml = `Active`;
+ } else {
+ statusHtml = `Active (${daysLeft}d)`;
+ }
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${c.name || "—"} |
+ ${c.id} |
+ ${c.email || "—"} |
+ ${expStr} |
+ ${statusHtml} |
+ ${c.online ? 'online' : 'offline'} ${formatLastActive(c.last_active)} |
+ ${formatBytes(c.total_bytes)} |
+ ${c.max_conns || "∞"} | `;
+ const actTd = document.createElement("td");
+ actTd.style.whiteSpace = "nowrap";
+ const copyBtn = document.createElement("button");
+ copyBtn.className = "btn btn-ghost btn-sm";
+ copyBtn.textContent = "Copy";
+ copyBtn.onclick = () => navigator.clipboard.writeText(c.id);
+ const editBtn = document.createElement("button");
+ editBtn.className = "btn btn-warn btn-sm";
+ editBtn.style.marginLeft = "4px";
+ editBtn.textContent = "Edit";
+ editBtn.onclick = () => openEditXrayClient(ib.tag, c);
+ const delBtn = document.createElement("button");
+ delBtn.className = "btn btn-danger btn-sm";
+ delBtn.style.marginLeft = "4px";
+ delBtn.textContent = "Del";
+ delBtn.onclick = () => removeClient(ib.tag, c.id);
+ actTd.append(copyBtn, editBtn, delBtn);
+ tr.appendChild(actTd);
+ tbody.appendChild(tr);
+ });
+ tbl.appendChild(tbody);
+ tblWrap.appendChild(tbl);
+ }
+ section.appendChild(tblWrap);
+
+ const divider = document.createElement("hr");
+ divider.style = "border:none;border-top:1px solid var(--border);margin-top:10px;";
+ section.appendChild(divider);
+
+ inboundsContainer.appendChild(section);
+ });
+}
+
+function openAddClient(tag) {
+ const form = document.getElementById(`add-form-${tag}`);
+ if (form) { form.classList.remove("hidden"); }
+ const uuidField = document.getElementById(`newUUID-${tag}`);
+ if (uuidField && !uuidField.value) uuidField.value = genUUID();
+}
+
+async function addClient(tag) {
+ const uuidEl = document.getElementById(`newUUID-${tag}`);
+ const emailEl = document.getElementById(`newEmail-${tag}`);
+ const nameEl = document.getElementById(`newName-${tag}`);
+ const expiryEl = document.getElementById(`newExpiry-${tag}`);
+ const maxConnsEl = document.getElementById(`newMaxConns-${tag}`);
+ const uuid = (uuidEl?.value || "").trim();
+ const email = (emailEl?.value || "").trim();
+ const name = (nameEl?.value || "").trim();
+ const expiresAt = isoFromLocal(expiryEl?.value || "");
+ const maxConns = parseInt(maxConnsEl?.value || "0", 10) || 0;
+ if (!uuid) { xStatus.textContent = "UUID required."; return; }
+ try {
+ const res = await api("/api/xray/clients/add", {
+ method: "POST",
+ body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns }),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ xStatus.textContent = `Client ${uuid.slice(0,8)}… added. Restarting Xray…`;
+ setTimeout(() => { loadInbounds(); if (currentRole === "reseller") loadMe(); }, 1500);
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else xStatus.textContent = "Error: "+e.message;
+ }
+}
+
+async function removeClient(tag, uuid) {
+ if (!confirm(`Remove client ${uuid.slice(0,8)}… from ${tag}?`)) return;
+ try {
+ const res = await api(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, { method:"DELETE" });
+ if (!res.ok && res.status !== 204) throw new Error(await res.text());
+ xStatus.textContent = "Client removed. Restarting Xray…";
+ setTimeout(() => { loadInbounds(); if (currentRole === "reseller") loadMe(); }, 1500);
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else xStatus.textContent = "Error: "+e.message;
+ }
+}
+
+async function loadXrayCfg() {
+ try {
+ const res = await api("/api/xray/config");
+ if (!res.ok) throw new Error(await res.text());
+ const text = await res.text();
+ try { xCfgEditor.value = JSON.stringify(JSON.parse(text), null, 2); }
+ catch { xCfgEditor.value = text; }
+ xCfgStatus.textContent = "Config loaded.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else xCfgStatus.textContent = "Error: "+e.message;
+ }
+}
+
+async function saveXrayCfg() {
+ const text = xCfgEditor.value.trim();
+ try { JSON.parse(text); } catch(e) { xCfgStatus.textContent = "Invalid JSON: "+e.message; return; }
+ xCfgStatus.textContent = "Saving…";
+ try {
+ const res = await api("/api/xray/config", { method:"POST", body: text });
+ if (!res.ok) throw new Error(await res.text());
+ xCfgStatus.textContent = "Saved. Restarting Xray…";
+ await xrayCtrl("restart");
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else xCfgStatus.textContent = "Error: "+e.message;
+ }
+}
+
+async function loadXrayLogs() {
+ try {
+ const res = await api("/api/xray/logs");
+ const data = await res.json();
+ xLogsBox.textContent = (data.lines||[]).join("\n");
+ xLogsBox.scrollTop = xLogsBox.scrollHeight;
+ } catch (e) { if (e.message==="auth") doAuthError(); }
+}
+
+// ─── Resellers ────────────────────────────────────────────────────────────────
+document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers);
+document.getElementById("newResellerBtn").addEventListener("click", () => {
+ resellerFormTitle.textContent = "Create Reseller";
+ resellerForm.reset();
+ rActive.checked = true;
+ resellerStatus.textContent = "New reseller.";
+});
+document.getElementById("cancelResellerBtn").addEventListener("click", () => {
+ resellerForm.reset();
+ rActive.checked = true;
+ resellerFormTitle.textContent = "Create Reseller";
+});
+
+document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
+
+async function loadResellers() {
+ resellerStatus.textContent = "Loading…";
+ try {
+ const res = await api("/api/resellers");
+ const data = await res.json();
+ renderResellers(data || []);
+ resellerStatus.textContent = "Loaded.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else resellerStatus.textContent = "Error loading.";
+ }
+}
+
+function renderResellers(list) {
+ resellerCountChip.textContent = list.length;
+ resellersBody.innerHTML = "";
+ list.forEach(r => {
+ const expired = r.expires_at && new Date(r.expires_at) < new Date();
+ const max = r.max_users || 0;
+ const used = r.used_users || 0;
+ const remaining = max ? Math.max(0, max - used) : "∞";
+ const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0;
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${r.username} |
+
+ ${used} / ${max || "∞"}
+ Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}
+
+ |
+ ${r.expires_at ? fmtDate(r.expires_at) : "—"} |
+ ${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"} |
+ | `;
+ const tdA = tr.lastElementChild;
+ const editBtn = Object.assign(document.createElement("button"),{
+ className:"btn btn-ghost btn-sm", textContent:"Edit",
+ onclick: () => fillResellerForm(r),
+ });
+ const delBtn = Object.assign(document.createElement("button"),{
+ className:"btn btn-danger btn-sm", textContent:"Del",
+ style: "margin-left:4px;",
+ onclick: () => deleteReseller(r.username),
+ });
+ tdA.append(editBtn, delBtn);
+ resellersBody.appendChild(tr);
+ });
+}
+
+function fillResellerForm(r) {
+ resellerFormTitle.textContent = `Edit: ${r.username}`;
+ rUsername.value = r.username;
+ rPassword.value = "";
+ rMaxUsers.value = r.max_users || 0;
+ rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
+ rActive.checked = r.is_active;
+ resellerStatus.textContent = `Editing ${r.username}.`;
+}
+
+resellerForm.addEventListener("submit", async e => {
+ e.preventDefault();
+ const btn = document.getElementById("saveResellerBtn");
+ btn.disabled = true;
+ resellerStatus.textContent = "Saving…";
+ const payload = {
+ username: rUsername.value.trim(),
+ password: rPassword.value || undefined,
+ max_users: parseInt(rMaxUsers.value||"0",10),
+ expires_at: isoFromLocal(rExpires.value),
+ is_active: rActive.checked,
+ };
+ try {
+ const res = await api("/api/resellers/create", { method:"POST", body: JSON.stringify(payload) });
+ if (!res.ok) throw new Error(await res.text());
+ resellerStatus.textContent = "Saved.";
+ resellerForm.reset(); rActive.checked = true;
+ resellerFormTitle.textContent = "Create Reseller";
+ loadResellers();
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else resellerStatus.textContent = "Error: "+e.message;
+ } finally { btn.disabled = false; }
+});
+
+async function deleteReseller(username) {
+ if (!confirm(`Delete reseller "${username}"? All their SSH sessions will be disconnected.`)) return;
+ resellerStatus.textContent = `Deleting ${username}…`;
+ try {
+ const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
+ if (!res.ok && res.status !== 204) throw new Error("failed");
+ resellerStatus.textContent = "Deleted.";
+ loadResellers();
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else resellerStatus.textContent = "Error deleting.";
+ }
+}
+
+// ─── Stats ────────────────────────────────────────────────────────────────────
+document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats);
+
+async function loadStats() {
+ try {
+ const res = await api("/api/stats");
+ const s = await res.json();
+ const cpu = s?.cpu_percent ?? 0;
+ cpuVal.textContent = fmtPct(cpu);
+ cpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%";
+ const mp = s?.mem_percent ?? null;
+ memVal.textContent = mp == null ? "--%" : fmtPct(mp);
+ memBar.style.width = mp == null ? "0%" : Math.min(100, Math.max(0, mp)) + "%";
+ const mu = s?.mem_used_bytes, mt = s?.mem_total_bytes;
+ memDetail.textContent = (mu != null && mt != null) ? `${fmtBytes(mu)} / ${fmtBytes(mt)}` : "";
+ const ifaces = Array.isArray(s.interfaces) ? s.interfaces : [];
+ ifaceBody.innerHTML = "";
+ let totRx = 0, totTx = 0;
+ ifaces.forEach(it => {
+ totRx += it.rx_bytes||0; totTx += it.tx_bytes||0;
+ const tr = document.createElement("tr");
+ tr.innerHTML = `${it.name} | ${fmtMbps(it.rx_mbps)} | ${fmtMbps(it.tx_mbps)} | ${fmtBytes(it.rx_bytes)} | ${fmtBytes(it.tx_bytes)} | `;
+ ifaceBody.appendChild(tr);
+ });
+ ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
+ statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString();
+ } catch (e) { if (e.message==="auth") doAuthError(); }
+}
+
+resetIfaceStatsBtn?.addEventListener("click", resetInterfaceStats);
+
+async function resetInterfaceStats() {
+ if (!confirm("Clean the live Interface totals now? This does not delete VnStat daily/monthly history.")) return;
+ resetIfaceStatsBtn.disabled = true;
+ ifaceSummary.textContent = "Cleaning interface totals…";
+ try {
+ const res = await api("/api/stats/interfaces/reset", { method:"POST" });
+ if (!res.ok) throw new Error(await res.text());
+ ifaceSummary.textContent = "Interface totals cleaned. Auto-clean remains every 30 days.";
+ loadStats();
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else ifaceSummary.textContent = "Error cleaning totals: " + e.message;
+ } finally {
+ resetIfaceStatsBtn.disabled = false;
+ }
+}
+
+// ─── VnStat ───────────────────────────────────────────────────────────────────
+document.querySelector("[data-tab='vnstat']")?.addEventListener("click", loadVnstat);
+reloadVnstatBtn?.addEventListener("click", loadVnstat);
+resetVnstatBtn?.addEventListener("click", resetVnstatHistory);
+
+function renderVnstatRows(body, rows, emptyLabel) {
+ body.innerHTML = "";
+ if (!rows.length) {
+ const tr = document.createElement("tr");
+ tr.innerHTML = `${emptyLabel} | `;
+ body.appendChild(tr);
+ return;
+ }
+ rows.forEach(r => {
+ const tr = document.createElement("tr");
+ tr.innerHTML = `${r.period || "--"} | ${r.iface || "--"} | ${fmtBytes(r.rx_bytes||0)} | ${fmtBytes(r.tx_bytes||0)} | ${fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0)))} | `;
+ body.appendChild(tr);
+ });
+}
+
+async function loadVnstat() {
+ vnstatStatus.textContent = "Loading VnStat usage…";
+ try {
+ const res = await api("/api/vnstat?days=31&months=12");
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ const daily = Array.isArray(data.daily) ? data.daily : [];
+ const monthly = Array.isArray(data.monthly) ? data.monthly : [];
+ renderVnstatRows(vnstatDailyBody, daily, "No daily usage recorded yet.");
+ renderVnstatRows(vnstatMonthlyBody, monthly, "No monthly usage recorded yet.");
+
+ // Use the server/database periods when available. Falling back to the
+ // newest row avoids browser UTC/local-time mismatches that can make
+ // "Today total" show 0 while the daily table has data.
+ const today = data.today_period || daily[0]?.period || localDateKey();
+ const month = data.month_period || today.slice(0,7);
+ const todayTotal = data.today_total_bytes ?? daily.filter(r => r.period === today).reduce((sum, r) => sum + (r.total_bytes||0), 0);
+ const monthTotal = data.month_total_bytes ?? monthly.filter(r => r.period === month).reduce((sum, r) => sum + (r.total_bytes||0), 0);
+ const ifaces = new Set([...daily, ...monthly].map(r => r.iface).filter(Boolean));
+ vnTodayTotal.textContent = fmtBytes(todayTotal);
+ vnMonthTotal.textContent = fmtBytes(monthTotal);
+ vnIfaceCount.textContent = String(data.interface_count ?? ifaces.size ?? 0);
+ vnstatStatus.textContent = "Updated: " + new Date().toLocaleTimeString() + " · history is kept until manually cleaned.";
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else vnstatStatus.textContent = "Error loading VnStat usage: " + e.message;
+ }
+}
+
+async function resetVnstatHistory() {
+ if (!confirm("Clean all VnStat daily/monthly usage history? This does not reset the live Interface totals.")) return;
+ resetVnstatBtn.disabled = true;
+ vnstatStatus.textContent = "Cleaning VnStat history…";
+ try {
+ const res = await api("/api/vnstat/reset", { method:"POST" });
+ if (!res.ok) throw new Error(await res.text());
+ vnstatStatus.textContent = "VnStat history cleaned.";
+ loadVnstat();
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else vnstatStatus.textContent = "Error cleaning VnStat history: " + e.message;
+ } finally {
+ resetVnstatBtn.disabled = false;
+ }
+}
+
+// ─── Logs ─────────────────────────────────────────────────────────────────────
+document.querySelector("[data-tab='logs']")?.addEventListener("click", loadSystemLogs);
+document.getElementById("logSource")?.addEventListener("change", loadSystemLogs);
+document.getElementById("clearPanelLogBtn")?.addEventListener("click", clearPanelLog);
+
+async function loadSystemLogs() {
+ const box = document.getElementById("systemLogBox");
+ const st = document.getElementById("systemLogStatus");
+ const source = document.getElementById("logSource")?.value || "panel";
+ const clearBtn = document.getElementById("clearPanelLogBtn");
+ if (clearBtn) clearBtn.disabled = source !== "panel";
+ st.textContent = "Loading…";
+ try {
+ const res = await api(`/api/system/logs?source=${encodeURIComponent(source)}&lines=500`);
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ const lines = Array.isArray(data.lines) ? data.lines : [];
+ box.textContent = lines.length ? lines.join("\n") : "No log lines yet.";
+ box.scrollTop = box.scrollHeight;
+ st.textContent = `${data.source || source} logs${data.path ? " · " + data.path : ""} · ${lines.length} lines · ` + new Date().toLocaleTimeString();
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+async function clearPanelLog() {
+ const st = document.getElementById("systemLogStatus");
+ if (!confirm("Clean the panel log now? Logs are already auto-cleaned after 1 MiB.")) return;
+ st.textContent = "Cleaning panel log…";
+ try {
+ const res = await api("/api/system/logs/reset", { method:"POST" });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ st.textContent = `Panel log cleaned · ${data.path || "panel.log"} · max ${fmtBytes(data.max_bytes || 1048576)}`;
+ await loadSystemLogs();
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error cleaning panel log: " + e.message;
+ }
+}
+
+// ─── Server Config ────────────────────────────────────────────────────────────
+document.querySelector("[data-tab='server']")?.addEventListener("click", loadServerConfig);
+
+function toggleDnsttFields(on) {
+ const el = document.getElementById("dnsttFields");
+ el.style.opacity = on ? "1" : ".4";
+ el.style.pointerEvents = on ? "" : "none";
+}
+function toggleUdpgwFields(on) {
+ const el = document.getElementById("udpgwFields");
+ el.style.opacity = on ? "1" : ".4";
+ el.style.pointerEvents = on ? "" : "none";
+}
+
+async function loadServerConfig() {
+ const st = document.getElementById("srvCfgStatus");
+ st.textContent = "Loading…";
+ try {
+ const res = await api("/api/server/config");
+ if (!res.ok) throw new Error(await res.text());
+ const c = await res.json();
+
+ // Network
+ document.getElementById("cfgListen").value = c.listen || "";
+ document.getElementById("cfgExtraListen").value = (c.extra_listen || []).join("\n");
+
+ // SSH / general
+ document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0;
+ document.getElementById("cfgLimitDown").value = c.default_limit_mbps_down || 0;
+ document.getElementById("cfgQuiet").checked = !!c.quiet;
+ document.getElementById("cfgUserCount").checked = !!c.user_count;
+
+ // Banner
+ document.getElementById("cfgBanner").value = c.banner || "";
+
+ // DNSTT
+ const hasDnstt = !!c.dnstt;
+ document.getElementById("cfgDnsttEnabled").checked = hasDnstt;
+ toggleDnsttFields(hasDnstt);
+ const d = c.dnstt || {};
+ document.getElementById("cfgDnsttDomain").value = d.domain || "";
+ document.getElementById("cfgDnsttUDP").value = d.udp_listen || "";
+ document.getElementById("cfgDnsttKey").value = d.privkey_file || "/opt/sshpanel/dnstt.key";
+ document.getElementById("cfgDnsttNoStats").checked = !!d.disable_stats_log;
+ document.getElementById("cfgDnsttNoConsole").checked = !!d.disable_console_log;
+
+ // UDPGW
+ const hasUdpgw = !!c.udpgw;
+ document.getElementById("cfgUdpgwEnabled").checked = hasUdpgw;
+ toggleUdpgwFields(hasUdpgw);
+ const u = c.udpgw || {};
+ document.getElementById("cfgUdpgwListen").value = u.listen || "";
+ document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0;
+ document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || "";
+ document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || "";
+ document.getElementById("cfgUdpgwDebug").checked = !!u.debug;
+
+ // TLS forwarders
+ tlsForwardersState = c.tls_forwarders || [];
+ renderTLSForwarders();
+
+ // Xray
+ const x = c.xray || {};
+ document.getElementById("cfgXrayEnabled").checked = !!x.enabled;
+
+ st.textContent = "Config loaded.";
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+async function saveServerConfig() {
+ const st = document.getElementById("srvCfgStatus");
+ st.textContent = "Saving…";
+
+ const tlsArr = tlsForwardersState;
+
+ const extraLines = document.getElementById("cfgExtraListen").value
+ .split("\n").map(s => s.trim()).filter(Boolean);
+
+ const cfg = {
+ listen: document.getElementById("cfgListen").value.trim(),
+ extra_listen: extraLines,
+ host_key_file: "/opt/sshpanel/ssh_host_rsa_key",
+ admin_dir: "/opt/sshpanel/admin",
+ default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
+ default_limit_mbps_down: parseInt(document.getElementById("cfgLimitDown").value || "0", 10),
+ quiet: document.getElementById("cfgQuiet").checked,
+ user_count: document.getElementById("cfgUserCount").checked,
+ banner: document.getElementById("cfgBanner").value,
+ banner_file: "/opt/sshpanel/banner.txt",
+ dnstt: document.getElementById("cfgDnsttEnabled").checked ? {
+ domain: document.getElementById("cfgDnsttDomain").value.trim(),
+ udp_listen: document.getElementById("cfgDnsttUDP").value.trim(),
+ privkey_file: document.getElementById("cfgDnsttKey").value.trim(),
+ disable_stats_log: document.getElementById("cfgDnsttNoStats").checked,
+ disable_console_log: document.getElementById("cfgDnsttNoConsole").checked,
+ } : null,
+ udpgw: document.getElementById("cfgUdpgwEnabled").checked ? {
+ listen: document.getElementById("cfgUdpgwListen").value.trim(),
+ max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10),
+ idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(),
+ map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(),
+ debug: document.getElementById("cfgUdpgwDebug").checked,
+ } : null,
+ tls_forwarders: tlsArr,
+ xray: {
+ enabled: document.getElementById("cfgXrayEnabled").checked,
+ bin_path: "/opt/sshpanel/xray",
+ config_file: "/opt/sshpanel/xray_config.json",
+ api_server: "127.0.0.1:10085",
+ online_window_seconds: 90,
+ stats_poll_seconds: 15,
+ },
+ };
+
+ try {
+ const res = await api("/api/server/config", { method: "POST", body: JSON.stringify(cfg) });
+ if (!res.ok) throw new Error(await res.text());
+ const report = await res.json().catch(() => null);
+ const warnings = report?.warnings || [];
+ const bad = Object.entries(report?.services || {}).filter(([_, v]) => v?.enabled && !v?.running);
+ if (warnings.length || bad.length) {
+ const badText = bad.map(([name, v]) => `${name}: ${v.error || "not running"}`).join(" | ");
+ st.textContent = "Saved live with warnings: " + [...warnings, badText].filter(Boolean).join(" | ");
+ } else {
+ st.textContent = "Saved and applied live.";
+ }
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+// ─── TLS Forwarders ────────────────────────────────────────────────────────────
+function renderTLSForwarders() {
+ const list = document.getElementById("tlsForwardersList");
+ const chip = document.getElementById("tlsCountChip");
+ if (!list) return;
+ chip.textContent = tlsForwardersState.length;
+ if (!tlsForwardersState.length) {
+ list.innerHTML = 'No TLS forwarders configured.
';
+ return;
+ }
+ list.innerHTML = "";
+ tlsForwardersState.forEach((fw, i) => {
+ const row = document.createElement("div");
+ row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
+ row.innerHTML = `${fw.listen}
+ ${fw.cert_file ? fw.cert_file.split("/").pop() : "no cert"}`;
+ const delBtn = document.createElement("button");
+ delBtn.className = "btn btn-danger btn-sm";
+ delBtn.textContent = "Remove";
+ delBtn.onclick = () => { tlsForwardersState.splice(i,1); renderTLSForwarders(); };
+ row.appendChild(delBtn);
+ list.appendChild(row);
+ });
+}
+
+function toggleAddTLSForm() {
+ const panel = document.getElementById("addTLSPanel");
+ panel.classList.toggle("hidden");
+ if (!panel.classList.contains("hidden")) {
+ document.getElementById("tlsAddStatus").textContent = "";
+ document.getElementById("tlsListenAddr").value = "";
+ document.getElementById("tlsSSLDomain").value = "";
+ document.getElementById("tlsCertType").value = "selfsigned";
+ onTLSTypeChange("selfsigned");
+ }
+}
+
+function onTLSTypeChange(val) {
+ document.getElementById("tlsSSFields").style.display = val === "selfsigned" ? "" : "none";
+ document.getElementById("tlsLEFields").style.display = val === "letsencrypt" ? "grid" : "none";
+ document.getElementById("tlsPasteFields").style.display = val === "paste" ? "" : "none";
+ document.getElementById("tlsCustomFields").style.display = val === "custom" ? "grid" : "none";
+}
+
+async function addTLSForwarder() {
+ const st = document.getElementById("tlsAddStatus");
+ const listen = document.getElementById("tlsListenAddr").value.trim();
+ const certType = document.getElementById("tlsCertType").value;
+ if (!listen) { st.textContent = "Listen address required."; return; }
+ let certFile = "", keyFile = "";
+ st.textContent = "Processing…";
+ if (certType === "selfsigned") {
+ const domain = document.getElementById("tlsSSLDomain").value.trim();
+ if (!domain) { st.textContent = "Domain required."; return; }
+ try {
+ const res = await api("/api/tls/generate-selfsigned", { method:"POST", body: JSON.stringify({ domain }) });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ certFile = data.cert_file; keyFile = data.key_file;
+ st.textContent = "Self-signed cert generated.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Cert error: " + e.message;
+ return;
+ }
+ } else if (certType === "letsencrypt") {
+ const domain = document.getElementById("tlsLEDomain").value.trim();
+ const email = document.getElementById("tlsLEEmail").value.trim();
+ if (!domain || !email) { st.textContent = "Domain and email required."; return; }
+ st.textContent = "Running certbot… (may take ~30s)";
+ try {
+ const res = await api("/api/tls/letsencrypt", { method:"POST", body: JSON.stringify({ domain, email }) });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ certFile = data.cert_file; keyFile = data.key_file;
+ st.textContent = "Let's Encrypt cert issued.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "certbot error: " + e.message;
+ return;
+ }
+ } else if (certType === "paste") {
+ const name = document.getElementById("tlsPasteName").value.trim();
+ const cert = document.getElementById("tlsPasteCert").value.trim();
+ const key = document.getElementById("tlsPasteKey").value.trim();
+ if (!name || !cert || !key) { st.textContent = "Name, cert PEM, and key PEM required."; return; }
+ try {
+ const res = await api("/api/tls/upload-pem", { method:"POST", body: JSON.stringify({ name, cert, key }) });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ certFile = data.cert_file; keyFile = data.key_file;
+ st.textContent = "PEM saved.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Upload error: " + e.message;
+ return;
+ }
+ } else {
+ certFile = document.getElementById("tlsCustomCert").value.trim();
+ keyFile = document.getElementById("tlsCustomKey").value.trim();
+ if (!certFile || !keyFile) { st.textContent = "Cert and key paths required."; return; }
+ }
+ tlsForwardersState.push({ listen, cert_file: certFile, key_file: keyFile });
+ renderTLSForwarders();
+ document.getElementById("addTLSPanel").classList.add("hidden");
+ st.textContent = "Added. Save config to apply.";
+}
+
+// ─── Xray wizard cert source picker ──────────────────────────────────────────
+function setWzCertSrc(mode) {
+ ["file","paste","gen"].forEach(m => {
+ const cap = m.charAt(0).toUpperCase() + m.slice(1);
+ document.getElementById("wzCertSrc"+cap).style.display = m === mode ? "" : "none";
+ const btn = document.getElementById("wzCertSrc"+cap+"Btn");
+ if (btn) btn.className = (m === mode ? "btn btn-sm" : "btn btn-ghost btn-sm");
+ });
+ document.getElementById("wzPasteCertStatus").textContent = "";
+ document.getElementById("wzGenCertStatus").textContent = "";
+}
+
+async function wzSavePastedCert() {
+ const st = document.getElementById("wzPasteCertStatus");
+ const name = document.getElementById("wzPastedName").value.trim();
+ const cert = document.getElementById("wzPastedCert").value.trim();
+ const key = document.getElementById("wzPastedKey").value.trim();
+ if (!name || !cert || !key) { st.textContent = "Name, cert, and key required."; return; }
+ st.textContent = "Saving…";
+ try {
+ const res = await api("/api/tls/upload-pem", { method:"POST", body: JSON.stringify({ name, cert, key }) });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ document.getElementById("wzTLSCert").value = data.cert_file;
+ document.getElementById("wzTLSKey").value = data.key_file;
+ st.textContent = "Saved ✓ paths set.";
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+async function wzGenerateCert() {
+ const st = document.getElementById("wzGenCertStatus");
+ const domain = document.getElementById("wzGenDomain").value.trim();
+ if (!domain) { st.textContent = "Domain required."; return; }
+ st.textContent = "Generating…";
+ try {
+ const res = await api("/api/tls/generate-selfsigned", { method:"POST", body: JSON.stringify({ domain }) });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ document.getElementById("wzTLSCert").value = data.cert_file;
+ document.getElementById("wzTLSKey").value = data.key_file;
+ st.textContent = "Generated ✓ paths set.";
+ } catch (e) {
+ if (e.message === "auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+// ─── DNSTT Key Management ─────────────────────────────────────────────────────
+async function generateDnsttKey() {
+ const st = document.getElementById("dnsttKeyStatus");
+ st.textContent = "Generating key…";
+ try {
+ const res = await api("/api/dnstt/genkey", { method: "POST" });
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ document.getElementById("cfgDnsttKey").value = data.privkey_file;
+ document.getElementById("dnsttPubkeyVal").value = data.pubkey;
+ document.getElementById("dnsttPubkeyWrap").classList.remove("hidden");
+ st.textContent = "Key generated. Save config to apply.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+async function loadDnsttPubkey() {
+ const st = document.getElementById("dnsttKeyStatus");
+ st.textContent = "Loading public key…";
+ try {
+ const res = await api("/api/dnstt/pubkey");
+ if (!res.ok) throw new Error(await res.text());
+ const data = await res.json();
+ document.getElementById("dnsttPubkeyVal").value = data.pubkey;
+ document.getElementById("dnsttPubkeyWrap").classList.remove("hidden");
+ st.textContent = "";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+// ─── Xray Client Edit ─────────────────────────────────────────────────────────
+function openEditXrayClient(tag, client) {
+ editingXrayClientId = client.id;
+ document.getElementById("editClientUUID").textContent = client.id;
+ document.getElementById("editXrayName").value = client.name || "";
+ document.getElementById("editXrayEmail").value = client.email || "";
+ document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
+ document.getElementById("editXrayMaxConns").value = client.max_conns || 0;
+ document.getElementById("editXrayClientStatus").textContent = "";
+ document.getElementById("editXrayClientPanel").classList.remove("hidden");
+ document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
+}
+
+function closeEditXrayClient() {
+ editingXrayClientId = null;
+ document.getElementById("editXrayClientPanel").classList.add("hidden");
+}
+
+async function saveEditXrayClient() {
+ if (!editingXrayClientId) return;
+ const st = document.getElementById("editXrayClientStatus");
+ st.textContent = "Saving…";
+ const payload = {
+ uuid: editingXrayClientId,
+ name: document.getElementById("editXrayName").value.trim(),
+ email: document.getElementById("editXrayEmail").value.trim(),
+ expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
+ max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
+ };
+ try {
+ const res = await api("/api/xray/clients/update", { method:"POST", body: JSON.stringify(payload) });
+ if (!res.ok) throw new Error(await res.text());
+ st.textContent = "Saved.";
+ setTimeout(() => { closeEditXrayClient(); loadInbounds(); }, 700);
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+// ─── Xray Config Wizard ────────────────────────────────────────────────────────
+function setXrayCfgMode(mode) {
+ const wizPane = document.getElementById("xrayWizardPane");
+ const jsonPane = document.getElementById("xrayCfgPaneJson");
+ const wizBtn = document.getElementById("xrayWizardTabBtn");
+ const jsonBtn = document.getElementById("xrayJsonTabBtn");
+ if (mode === "wizard") {
+ wizPane.classList.remove("hidden");
+ jsonPane.classList.add("hidden");
+ wizBtn.classList.remove("btn-ghost");
+ jsonBtn.classList.add("btn-ghost");
+ loadWizardFromConfig();
+ } else {
+ wizPane.classList.add("hidden");
+ jsonPane.classList.remove("hidden");
+ jsonBtn.classList.remove("btn-ghost");
+ wizBtn.classList.add("btn-ghost");
+ loadXrayCfg();
+ }
+}
+
+function loadWizardFromConfig() {
+ api("/api/xray/config").then(async res => {
+ if (!res.ok) return;
+ try {
+ const cfg = JSON.parse(await res.text());
+ document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning";
+ wzInbounds = (cfg.inbounds || []).filter(ib => ib.tag !== "api");
+ renderWzInbounds();
+ } catch {}
+ }).catch(() => {});
+}
+
+function renderWzInbounds() {
+ const list = document.getElementById("wzInboundsList");
+ if (!list) return;
+ if (!wzInbounds.length) {
+ list.innerHTML = 'No inbounds. Click + Add to create one.
';
+ return;
+ }
+ list.innerHTML = "";
+ wzInbounds.forEach((ib, i) => {
+ const row = document.createElement("div");
+ row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
+ const portStr = ib.port !== undefined ? `:${ib.port}` : "";
+ const ss = ib.streamSettings || {};
+ const net = ss.network || "";
+ const sec = ss.security || "";
+ const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : "";
+ const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : "";
+ row.innerHTML = `${ib.protocol}
+ ${ib.tag||"untagged"}${portStr}
+ ${ib.listen||"0.0.0.0"}${net?" · "+net:""}${modeLabel}${secLabel}`;
+ const clients = ib.settings?.clients;
+ if (Array.isArray(clients) && clients.length) {
+ const badge = document.createElement("span");
+ badge.className = "chip green";
+ badge.textContent = clients.length + " client" + (clients.length!==1?"s":"");
+ row.appendChild(badge);
+ }
+ const delBtn = document.createElement("button");
+ delBtn.className = "btn btn-danger btn-sm";
+ delBtn.textContent = "Remove";
+ delBtn.onclick = () => { wzInbounds.splice(i,1); renderWzInbounds(); };
+ row.appendChild(delBtn);
+ list.appendChild(row);
+ });
+}
+
+function wzToggleAddInbound() {
+ const form = document.getElementById("wzAddInboundForm");
+ form.classList.toggle("hidden");
+ if (!form.classList.contains("hidden")) {
+ onWzProtoChange(document.getElementById("wzProtocol").value);
+ onWzNetworkChange(document.getElementById("wzNetwork").value);
+ onWzTLSChange(document.getElementById("wzTLS").value);
+ }
+}
+
+function onWzProtoChange(val) {
+ const usesClientTransport = val === "vless" || val === "vmess";
+ document.getElementById("wzVlessFields").style.display = usesClientTransport ? "grid" : "none";
+ document.getElementById("wzTrojanFields").style.display = val === "trojan" ? "" : "none";
+ document.getElementById("wzSSFields").style.display = val === "shadowsocks" ? "grid" : "none";
+
+ const tlsSel = document.getElementById("wzTLS");
+ const realityOpt = document.querySelector("#wzTLS option[value='reality']");
+ if (realityOpt) {
+ realityOpt.disabled = val === "vmess";
+ if (val === "vmess" && tlsSel.value === "reality") {
+ tlsSel.value = "none";
+ onWzTLSChange("none");
+ }
+ }
+
+ const portMap = { vless:10086, vmess:10087, trojan:8443, shadowsocks:8388, socks:10808 };
+ const tagMap = { vless:"vless-in", vmess:"vmess-in", trojan:"trojan-in", shadowsocks:"ss-in", socks:"socks-local" };
+ const portEl = document.getElementById("wzPort");
+ const tagEl = document.getElementById("wzTag");
+ const lisEl = document.getElementById("wzListenIP");
+ const knownPorts = Object.values(portMap).map(String);
+ const knownTags = Object.values(tagMap);
+ if (!portEl.value || knownPorts.includes(portEl.value)) portEl.value = portMap[val] || "";
+ if (!tagEl.value || knownTags.includes(tagEl.value)) tagEl.value = tagMap[val] || val+"-in";
+ if (!lisEl.value || lisEl.value === "0.0.0.0" || lisEl.value === "127.0.0.1") {
+ lisEl.value = val === "socks" ? "127.0.0.1" : "0.0.0.0";
+ }
+}
+
+function onWzNetworkChange(val) {
+ const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none";
+ // WebSocket
+ show("wzWSPathField", val === "ws");
+ // XHTTP
+ show("wzXHTTPPathField", val === "xhttp");
+ show("wzXHTTPHostField", val === "xhttp");
+ show("wzXHTTPModeField", val === "xhttp");
+ // HTTPUpgrade
+ show("wzHUPathField", val === "httpupgrade");
+ show("wzHUHostField", val === "httpupgrade");
+ // H2
+ show("wzH2PathField", val === "h2");
+ show("wzH2HostField", val === "h2");
+ // gRPC
+ show("wzGRPCServiceField", val === "grpc");
+ show("wzGRPCMultiField", val === "grpc");
+ // Auto-select TLS defaults
+ const tlsSel = document.getElementById("wzTLS");
+ if ((val === "h2" || val === "grpc") && tlsSel.value === "none") {
+ tlsSel.value = "tls"; onWzTLSChange("tls");
+ }
+}
+
+function onWzTLSChange(val) {
+ const show = (id, v) => document.getElementById(id).style.display = v ? "" : "none";
+ show("wzTLSCertBlock", val === "tls");
+ show("wzRealityDestField", val === "reality");
+ show("wzRealitySNIField", val === "reality");
+ show("wzRealityPrivField", val === "reality");
+ show("wzRealityShortIDField",val === "reality");
+}
+
+function wzSaveInbound() {
+ const proto = document.getElementById("wzProtocol").value;
+ const port = parseInt(document.getElementById("wzPort").value || "0", 10);
+ const listen = document.getElementById("wzListenIP").value.trim() || "0.0.0.0";
+ const tag = document.getElementById("wzTag").value.trim() || proto+"-in";
+ if (!port) { alert("Port required."); return; }
+ const ib = { tag, port, listen, protocol: proto, settings: {} };
+ if (proto === "vless" || proto === "vmess") {
+ ib.settings = proto === "vless" ? { clients: [], decryption: "none" } : { clients: [] };
+ const net = document.getElementById("wzNetwork").value;
+ const tlsVal = document.getElementById("wzTLS").value;
+ ib.streamSettings = { network: net };
+ // Transport-specific settings
+ switch (net) {
+ case "ws":
+ ib.streamSettings.wsSettings = { path: document.getElementById("wzWSPath").value.trim() || "/" };
+ break;
+ case "xhttp":
+ ib.streamSettings.xhttpSettings = {
+ path: document.getElementById("wzXHTTPPath").value.trim() || "/",
+ host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
+ mode: document.getElementById("wzXHTTPMode").value,
+ };
+ if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
+ break;
+ case "httpupgrade":
+ ib.streamSettings.httpupgradeSettings = {
+ path: document.getElementById("wzHUPath").value.trim() || "/",
+ host: document.getElementById("wzHUHost").value.trim() || undefined,
+ };
+ if (!ib.streamSettings.httpupgradeSettings.host) delete ib.streamSettings.httpupgradeSettings.host;
+ break;
+ case "h2":
+ ib.streamSettings.httpSettings = {
+ path: document.getElementById("wzH2Path").value.trim() || "/",
+ host: [document.getElementById("wzH2Host").value.trim()].filter(Boolean),
+ };
+ break;
+ case "grpc":
+ ib.streamSettings.grpcSettings = {
+ serviceName: document.getElementById("wzGRPCService").value.trim() || "grpc",
+ multiMode: document.getElementById("wzGRPCMulti").checked,
+ };
+ break;
+ }
+ // TLS / Reality
+ if (tlsVal === "tls") {
+ ib.streamSettings.security = "tls";
+ ib.streamSettings.tlsSettings = {
+ certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }],
+ };
+ } else if (tlsVal === "reality" && proto === "vless") {
+ ib.streamSettings.security = "reality";
+ ib.streamSettings.realitySettings = {
+ dest: document.getElementById("wzRealityDest").value.trim(),
+ serverNames: [document.getElementById("wzRealitySNI").value.trim()].filter(Boolean),
+ privateKey: document.getElementById("wzRealityPriv").value.trim(),
+ shortIds: [document.getElementById("wzRealityShortID").value.trim()].filter(Boolean),
+ };
+ }
+ } else if (proto === "trojan") {
+ ib.settings = { clients: [{ password: document.getElementById("wzTrojanPass").value.trim() || "change-me" }] };
+ ib.streamSettings = { network: "tcp", security: "tls", tlsSettings: {} };
+ } else if (proto === "shadowsocks") {
+ ib.settings = { method: document.getElementById("wzSSMethod").value, password: document.getElementById("wzSSPass").value.trim() || "change-me", network: "tcp,udp" };
+ } else if (proto === "socks") {
+ ib.settings = { auth: "noauth", udp: true };
+ ib.streamSettings = { network: "tcp" };
+ }
+ wzInbounds.push(ib);
+ renderWzInbounds();
+ document.getElementById("wzAddInboundForm").classList.add("hidden");
+ document.getElementById("wzPort").value = "";
+ document.getElementById("wzTag").value = "";
+ document.getElementById("wzListenIP").value = "";
+}
+
+async function applyWizardConfig() {
+ const st = document.getElementById("wzStatus");
+ st.textContent = "Saving…";
+ const apiInbound = {
+ tag: "api",
+ listen: "127.0.0.1",
+ port: 10085,
+ protocol: "dokodemo-door",
+ settings: { address: "127.0.0.1" }
+ };
+ const userInbounds = (wzInbounds || []).filter(ib => ib.tag !== "api");
+ const cfg = {
+ log: { loglevel: document.getElementById("wzLogLevel").value },
+ api: { tag: "api", services: ["HandlerService", "LoggerService", "StatsService"] },
+ stats: {},
+ policy: { levels: { "0": { statsUserUplink: true, statsUserDownlink: true } }, system: { statsInboundUplink: true, statsInboundDownlink: true } },
+ inbounds: [apiInbound, ...userInbounds],
+ outbounds: [
+ { tag:"direct", protocol:"freedom", settings:{} },
+ { tag:"blocked", protocol:"blackhole", settings:{} },
+ { tag:"api", protocol:"freedom", settings:{} }
+ ],
+ routing: { rules: [{ type: "field", inboundTag: ["api"], outboundTag: "api" }] }
+ };
+ try {
+ const res = await api("/api/xray/config", { method:"POST", body: JSON.stringify(cfg, null, 2) });
+ if (!res.ok) throw new Error(await res.text());
+ st.textContent = "Saved. Restarting Xray…";
+ await xrayCtrl("restart");
+ st.textContent = "Config saved and Xray restarted.";
+ } catch (e) {
+ if (e.message==="auth") doAuthError();
+ else st.textContent = "Error: " + e.message;
+ }
+}
+
+// ─── Auth error ───────────────────────────────────────────────────────────────
+function doAuthError() {
+ sessionToken = "";
+ localStorage.removeItem("SESSION_TOKEN");
+ clearTimers();
+ mainApp.classList.add("hidden");
+ loginOverlay.classList.remove("hidden");
+ loginErr.textContent = "Session expired — please sign in again.";
+}
+
+// ─── Boot ─────────────────────────────────────────────────────────────────────
+window.addEventListener("load", () => {
+ if (sessionToken) {
+ // Try to validate the stored token
+ api("/api/auth/me").then(async res => {
+ if (!res.ok) { doAuthError(); return; }
+ const d = await res.json();
+ currentRole = d.role;
+ currentUser = d.username;
+ loginOverlay.classList.add("hidden");
+ mainApp.classList.remove("hidden");
+ initAfterLogin();
+ }).catch(() => doAuthError());
+ } else {
+ loginOverlay.classList.remove("hidden");
+ }
+});
diff --git a/admin/index.html b/admin/index.html
index 48cb837..efb9596 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -2,145 +2,9 @@
-SSH Panel
+DragonCore Panel
-
+
@@ -160,28 +24,140 @@ html,body{width:100%;max-width:100%;overflow-x:hidden;}
+
+
+
+
+
+
+
+
+ Painel
+ Visão geral
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
- SSH Panel
-
-
-
-
-
+
+
+
+
+
Painel operacional
+
Bem-vindo de volta 👋
+
Gerencie SSH, Xray, revendedores e servidor em poucos cliques.
+
+
+
+
+
-
-
-
+
+
+
+ Total de contas
+ --
+ -- ativas · -- expiradas
+ SSH -- · Xray --
+
+
👥
+
+
+
+ Servidores
+ --
+ monitoramento em tempo real
+
+
▣
+
+
+
+
Limite disponível
+
--
+
Carregando cota…
+
+
+
✓
+
+
+
+ Conexões ativas
+ --
+ SSH + Xray online agora
+
+
⌁
+
+
+
+ Xray clients
+ --
+ Pronto para revendedores
+
+
◇
+
+
+
+
+
+
+
Ações rápidas
+
simples
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -283,21 +259,34 @@ html,body{width:100%;max-width:100%;overflow-x:hidden;}
+
+
Área do revendedor
cota única
+
Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.
+
+ --usadas
+ --disponíveis
+ SSH -- · Xray --divisão
+
+
+
Xray Core --
-
+
+
Ready.
@@ -314,7 +303,7 @@ html,body{width:100%;max-width:100%;overflow-x:hidden;}
-
+
Xray Config
@@ -488,7 +477,7 @@ html,body{width:100%;max-width:100%;overflow-x:hidden;}
-
+
Logs last 200 lines
@@ -838,7 +827,7 @@ html,body{width:100%;max-width:100%;overflow-x:hidden;}
-
Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json
+
Binary: /opt/sshpanel/xray · Config: /opt/sshpanel/xray_config.json · Online counters use Xray Stats API on 127.0.0.1:10085
@@ -853,1564 +842,13 @@ html,body{width:100%;max-width:100%;overflow-x:hidden;}
+
+
+
-
+
diff --git a/admin_script.js b/admin_script.js
index 64ce1ad..e595009 100644
--- a/admin_script.js
+++ b/admin_script.js
@@ -1,3 +1,4 @@
+
// ─── State ───────────────────────────────────────────────────────────────────
let sessionToken = localStorage.getItem("SESSION_TOKEN") || "";
let currentRole = "";
@@ -18,6 +19,26 @@ const mainApp = document.getElementById("mainApp");
const meUsername = document.getElementById("meUsername");
const roleChip = document.getElementById("roleChip");
const logoutBtn = document.getElementById("logoutBtn");
+const menuToggle = document.getElementById("menuToggle");
+const drawerBackdrop = document.getElementById("drawerBackdrop");
+const themeToggle = document.getElementById("themeToggle");
+const pageTitle = document.getElementById("pageTitle");
+const pageEyebrow = document.getElementById("pageEyebrow");
+const sidebarUsername = document.getElementById("sidebarUsername");
+const sidebarRole = document.getElementById("sidebarRole");
+const dashTotalUsers = document.getElementById("dashTotalUsers");
+const dashActiveUsers = document.getElementById("dashActiveUsers");
+const dashExpiredUsers = document.getElementById("dashExpiredUsers");
+const dashConnections = document.getElementById("dashConnections");
+const dashServers = document.getElementById("dashServers");
+const dashServerStatus = document.getElementById("dashServerStatus");
+const dashXrayClients = document.getElementById("dashXrayClients");
+const dashXrayStatus = document.getElementById("dashXrayStatus");
+const dashQuotaChip = document.getElementById("dashQuotaChip");
+const dashQuotaBar = document.getElementById("dashQuotaBar");
+const dashQuotaText = document.getElementById("dashQuotaText");
+const dashQuotaBreakdown = document.getElementById("dashQuotaBreakdown");
+const dashboardQuotaCard = document.getElementById("dashboardQuotaCard");
// Users
const usersBody = document.getElementById("usersBody");
@@ -153,15 +174,48 @@ function genUUID() {
(c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16));
}
-// ─── Tab switching ────────────────────────────────────────────────────────────
-document.querySelectorAll(".tab-btn").forEach(btn => {
- btn.addEventListener("click", () => {
- document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
- document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
- btn.classList.add("active");
- document.getElementById("tab-" + btn.dataset.tab).classList.add("active");
- });
-});
+// ─── Navigation / shell ──────────────────────────────────────────────────────
+const tabTitles = {
+ dashboard: ["Painel", "Visão geral"],
+ ssh: ["Contas", "SSH / SlowDNS"],
+ xray: ["Contas", "Xray Users"],
+ resellers: ["Administração", "Revendedores"],
+ stats: ["Servidor", "Monitoramento"],
+ vnstat: ["Tráfego", "VnStat"],
+ logs: ["Sistema", "Logs"],
+ server: ["Sistema", "Configurações"],
+};
+
+function selectTab(tab) {
+ const pane = document.getElementById("tab-" + tab);
+ const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`);
+ if (!pane || !btn) return;
+ document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
+ document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
+ btn.classList.add("active");
+ pane.classList.add("active");
+ const [eyebrow, title] = tabTitles[tab] || ["Painel", tab];
+ if (pageEyebrow) pageEyebrow.textContent = eyebrow;
+ if (pageTitle) pageTitle.textContent = title;
+ document.body.classList.remove("sidebar-open");
+
+ if (tab === "dashboard") refreshDashboard();
+ if (tab === "xray") {
+ loadXrayStatus();
+ loadInbounds();
+ if (currentRole === "superadmin") loadWizardFromConfig();
+ }
+ if (tab === "stats" && currentRole === "superadmin") loadStats();
+ if (tab === "resellers" && currentRole === "superadmin") loadResellers();
+}
+
+document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab)));
+menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
+drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
+themeToggle?.addEventListener("click", () => document.body.classList.toggle("light-mode"));
+document.querySelectorAll(".quick-action[data-jump]").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.jump)));
+document.getElementById("quickCreateUserBtn")?.addEventListener("click", () => { selectTab("ssh"); setFormCollapsed(false); fUsername?.focus(); });
+document.getElementById("quickOpenXrayBtn")?.addEventListener("click", () => selectTab("xray"));
// ─── Login / Logout ───────────────────────────────────────────────────────────
loginBtn.addEventListener("click", doLogin);
@@ -215,31 +269,34 @@ function clearTimers() {
function initAfterLogin() {
meUsername.textContent = currentUser;
+ if (sidebarUsername) sidebarUsername.textContent = currentUser;
+ if (sidebarRole) sidebarRole.textContent = currentRole === "superadmin" ? "Super Admin" : "Revendedor";
roleChip.innerHTML = currentRole === "superadmin"
? `
superadmin`
: `
reseller`;
- // Show/hide superadmin-only elements
document.querySelectorAll(".superadmin-only").forEach(el => {
el.classList.toggle("hidden", currentRole !== "superadmin");
});
+ document.querySelectorAll(".xray-admin-only").forEach(el => {
+ el.classList.toggle("hidden", currentRole !== "superadmin");
+ });
- // Reset to SSH tab
- document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
- document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
- document.querySelector("[data-tab='ssh']").classList.add("active");
- document.getElementById("tab-ssh").classList.add("active");
+ resellerInfoCard.classList.toggle("hidden", currentRole !== "reseller");
+ dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller");
+
+ selectTab("dashboard");
if (currentRole === "superadmin") {
loadStats();
statsTimer = setInterval(loadStats, 2000);
- xrayTimer = setInterval(loadXrayStatus, 5000);
} else {
- resellerInfoCard.classList.remove("hidden");
loadMe();
}
+ xrayTimer = setInterval(loadXrayStatus, 7000);
loadUsers();
+ loadInbounds();
usersTimer = setInterval(() => loadUsersSilent(), 3000);
}
@@ -248,13 +305,56 @@ async function loadMe() {
try {
const res = await api("/api/auth/me");
const d = await res.json();
- rUsedMax.textContent = (d.used_users ?? "--") + " / " + (d.max_users || "∞");
+ const used = d.used_users ?? 0;
+ const max = d.max_users || 0;
+ rUsedMax.textContent = used + " / " + (max || "∞");
rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : "No limit";
rStatus.textContent = d.is_active ? "Active" : "Suspended";
rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)";
+ updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0);
} catch {}
}
+function updateQuotaCard(used, max, sshUsed = 0, xrayUsed = 0) {
+ if (!dashQuotaText) return;
+ const labelMax = max || "∞";
+ dashQuotaChip.textContent = `${used} / ${labelMax}`;
+ dashQuotaText.textContent = max ? `${Math.max(0, max - used)} contas disponíveis` : "Sem limite definido pelo admin";
+ dashQuotaBreakdown.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`;
+ const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0;
+ dashQuotaBar.style.width = `${pct}%`;
+}
+
+function updateDashboardFromUsers(users = []) {
+ if (!dashTotalUsers) return;
+ const now = new Date();
+ let active = 0, expired = 0, conns = 0;
+ users.forEach(u => {
+ conns += Number(u.active_conns || 0);
+ if (u.expires_at && new Date(u.expires_at) < now) expired++;
+ else active++;
+ });
+ dashTotalUsers.textContent = users.length;
+ dashActiveUsers.textContent = active;
+ dashExpiredUsers.textContent = expired;
+ dashConnections.textContent = conns;
+}
+
+function updateDashboardXray(inbounds = []) {
+ if (!dashXrayClients) return;
+ const total = inbounds.reduce((sum, ib) => sum + ((ib.clients || []).length), 0);
+ dashXrayClients.textContent = total;
+ const running = xrayChip?.textContent || "--";
+ dashXrayStatus.textContent = `Core: ${running}`;
+}
+
+function refreshDashboard() {
+ loadUsersSilent();
+ loadInbounds();
+ loadXrayStatus();
+ if (currentRole === "reseller") loadMe();
+}
+
// ─── SSH Users ────────────────────────────────────────────────────────────────
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
newUserBtn.addEventListener("click", () => {
@@ -304,6 +404,7 @@ async function loadUsersSilent() {
}
function renderUsers(users) {
+ updateDashboardFromUsers(users);
const isSA = currentRole === "superadmin";
userCountChip.textContent = users.length;
if (isSA) ownerColHead.classList.remove("hidden");
@@ -418,7 +519,9 @@ document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
document.querySelector("[data-tab='xray']")?.addEventListener("click", () => {
- loadXrayStatus(); loadInbounds(); loadWizardFromConfig();
+ loadXrayStatus();
+ loadInbounds();
+ if (currentRole === "superadmin") loadWizardFromConfig();
});
async function loadXrayStatus() {
@@ -432,6 +535,9 @@ async function loadXrayStatus() {
xRunning.style.color = run ? "var(--success)" : "var(--danger)";
xPID.textContent = s.pid || "--";
xUptime.textContent = s.uptime || "--";
+ if (dashServers) dashServers.textContent = s.enabled ? "1" : "0";
+ if (dashServerStatus) dashServerStatus.textContent = run ? "1 online" : (s.enabled ? "parado" : "desativado");
+ if (dashXrayStatus) dashXrayStatus.textContent = `Core: ${xrayChip.textContent}`;
if (s.error) xStatus.textContent = "Error: " + s.error;
} catch (e) { if (e.message==="auth") doAuthError(); }
}
@@ -463,6 +569,7 @@ async function loadInbounds() {
}
function renderInbounds(inbounds) {
+ updateDashboardXray(inbounds);
if (!inbounds.length) {
inboundsContainer.innerHTML = '
No VLESS/VMess/Trojan inbounds found.
';
return;
@@ -694,7 +801,7 @@ function renderResellers(list) {
const tr = document.createElement("tr");
tr.innerHTML = `
${r.username} |
-
${r.used_users} / ${r.max_users || "∞"} |
+
${r.used_users} / ${r.max_users || "∞"} SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0} |
${r.expires_at ? fmtDate(r.expires_at) : "—"} |
${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"} |
| `;
diff --git a/auth.go b/auth.go
index eb47f5b..930853c 100644
--- a/auth.go
+++ b/auth.go
@@ -443,6 +443,7 @@ func startResellerExpiryChecker(store *Store) {
u.IsActive = false
adminUsers.set(u)
disconnectOwnerUsers(u.Username)
+ removeOwnerXrayClients(ctx, store, u.Username)
}
// Reactivate resellers that have been renewed (inactive but expiry now in future/nil)
@@ -537,7 +538,9 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
if s.Role == RoleReseller {
if u, ok := adminUsers.get(s.Username); ok {
resp["max_users"] = u.MaxUsers
- resp["used_users"] = countOwnedUsers(s.Username)
+ resp["used_users"] = countOwnedQuota(r.Context(), statsStore, s.Username)
+ resp["used_ssh_users"] = countOwnedUsers(s.Username)
+ resp["used_xray_users"] = countOwnedXrayClients(r.Context(), statsStore, s.Username)
resp["expires_at"] = u.ExpiresAt
resp["is_active"] = u.IsActive
}
@@ -554,6 +557,8 @@ type ResellerDTO struct {
Role string `json:"role"`
MaxUsers int `json:"max_users"`
UsedUsers int `json:"used_users"`
+ UsedSSH int `json:"used_ssh_users"`
+ UsedXray int `json:"used_xray_users"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
@@ -577,7 +582,9 @@ func handleListResellers(store *Store) http.HandlerFunc {
Username: u.Username,
Role: u.Role,
MaxUsers: u.MaxUsers,
- UsedUsers: countOwnedUsers(u.Username),
+ UsedUsers: countOwnedQuota(r.Context(), store, u.Username),
+ UsedSSH: countOwnedUsers(u.Username),
+ UsedXray: countOwnedXrayClients(r.Context(), store, u.Username),
ExpiresAt: u.ExpiresAt,
IsActive: u.IsActive,
CreatedAt: u.CreatedAt,
@@ -652,8 +659,12 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
}
adminUsers.set(u)
- // If reseller was reactivated, users can reconnect automatically.
- // Reconnect of existing SSH connections happens via the expiry checker.
+ if u.Role == RoleReseller {
+ if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
+ disconnectOwnerUsers(u.Username)
+ removeOwnerXrayClients(ctx, store, u.Username)
+ }
+ }
w.WriteHeader(http.StatusCreated)
}
@@ -676,6 +687,7 @@ func handleDeleteReseller(store *Store) http.HandlerFunc {
return
}
disconnectOwnerUsers(username)
+ removeOwnerXrayClients(ctx, store, username)
adminUsers.delete(username)
w.WriteHeader(http.StatusNoContent)
}
diff --git a/main.go b/main.go
index 81af77c..6bce33e 100644
--- a/main.go
+++ b/main.go
@@ -1325,17 +1325,19 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
mux.Handle("/api/resellers/create", saSession(http.HandlerFunc(handleCreateReseller(store))))
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store))))
- // Superadmin-only: Xray-core management
- mux.Handle("/api/xray/status", saSession(http.HandlerFunc(handleXrayStatus)))
+ // Xray-core management. Service/config/log actions are superadmin-only;
+ // authenticated resellers may list inbounds and manage their own Xray clients.
+ mux.Handle("/api/xray/status", sessionMiddleware(http.HandlerFunc(handleXrayStatus)))
mux.Handle("/api/xray/start", saSession(http.HandlerFunc(handleXrayStart)))
mux.Handle("/api/xray/stop", saSession(http.HandlerFunc(handleXrayStop)))
mux.Handle("/api/xray/restart", saSession(http.HandlerFunc(handleXrayRestart)))
+ mux.Handle("/api/xray/stats/repair", saSession(http.HandlerFunc(handleXrayRepairStats)))
mux.Handle("/api/xray/config", saSession(http.HandlerFunc(handleXrayConfig)))
mux.Handle("/api/xray/logs", saSession(http.HandlerFunc(handleXrayLogs)))
- mux.Handle("/api/xray/inbounds", saSession(http.HandlerFunc(handleXrayInbounds)))
- mux.Handle("/api/xray/clients/add", saSession(http.HandlerFunc(handleXrayClientAdd)))
- mux.Handle("/api/xray/clients/update", saSession(http.HandlerFunc(handleXrayClientUpdate)))
- mux.Handle("/api/xray/clients/remove", saSession(http.HandlerFunc(handleXrayClientRemove)))
+ mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
+ mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
+ mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
+ mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
// Superadmin-only: TLS certificate generation
mux.Handle("/api/tls/generate-selfsigned", saSession(http.HandlerFunc(handleTLSGenerateSelfSigned)))
@@ -1508,7 +1510,7 @@ func handleCreateUser(store *Store) http.HandlerFunc {
).Scan(&existsInDB)
if !existsInDB {
owner, ok := adminUsers.get(sess.Username)
- if ok && owner.MaxUsers > 0 && countOwnedUsers(sess.Username) >= owner.MaxUsers {
+ if ok && owner.MaxUsers > 0 && countOwnedQuota(ctx, store, sess.Username) >= owner.MaxUsers {
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return
}
diff --git a/xray_clients.go b/xray_clients.go
index 4813038..3cae163 100644
--- a/xray_clients.go
+++ b/xray_clients.go
@@ -8,29 +8,39 @@ import (
)
// XrayClientMeta holds metadata stored in PostgreSQL for an Xray client.
-// Xray's own config only stores uuid/email/level; expiry and display name live here.
+// Xray's own config only stores uuid/email/level; expiry, display name,
+// reseller owner, and connection policy live here.
type XrayClientMeta struct {
- UUID string
- Name string
- Email string
- InboundTag string
- ExpiresAt *time.Time
- MaxConns int
- CreatedAt time.Time
+ UUID string
+ Name string
+ Email string
+ InboundTag string
+ OwnerUsername string
+ ExpiresAt *time.Time
+ MaxConns int
+ CreatedAt time.Time
}
func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
- _, err := s.db.ExecContext(ctx, `
- CREATE TABLE IF NOT EXISTS xray_clients (
- uuid TEXT PRIMARY KEY,
- name TEXT NOT NULL DEFAULT '',
- email TEXT NOT NULL DEFAULT '',
- inbound_tag TEXT NOT NULL DEFAULT '',
- expires_at TIMESTAMPTZ,
- max_conns INT NOT NULL DEFAULT 0,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
- )`)
- return err
+ stmts := []string{
+ `CREATE TABLE IF NOT EXISTS xray_clients (
+ uuid TEXT PRIMARY KEY,
+ name TEXT NOT NULL DEFAULT '',
+ email TEXT NOT NULL DEFAULT '',
+ inbound_tag TEXT NOT NULL DEFAULT '',
+ owner_username TEXT NOT NULL DEFAULT '',
+ expires_at TIMESTAMPTZ,
+ max_conns INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ )`,
+ `ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`,
+ }
+ for _, stmt := range stmts {
+ if _, err := s.db.ExecContext(ctx, stmt); err != nil {
+ return err
+ }
+ }
+ return nil
}
func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) error {
@@ -39,15 +49,16 @@ func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) erro
expiresAt = *m.ExpiresAt
}
_, err := s.db.ExecContext(ctx, `
- INSERT INTO xray_clients (uuid, name, email, inbound_tag, expires_at, max_conns)
- VALUES ($1, $2, $3, $4, $5, $6)
+ INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (uuid) DO UPDATE SET
- name = EXCLUDED.name,
- email = EXCLUDED.email,
- inbound_tag = EXCLUDED.inbound_tag,
- expires_at = EXCLUDED.expires_at,
- max_conns = EXCLUDED.max_conns`,
- m.UUID, m.Name, m.Email, m.InboundTag, expiresAt, m.MaxConns)
+ name = EXCLUDED.name,
+ email = EXCLUDED.email,
+ inbound_tag = CASE WHEN EXCLUDED.inbound_tag <> '' THEN EXCLUDED.inbound_tag ELSE xray_clients.inbound_tag END,
+ owner_username = CASE WHEN EXCLUDED.owner_username <> '' THEN EXCLUDED.owner_username ELSE xray_clients.owner_username END,
+ expires_at = EXCLUDED.expires_at,
+ max_conns = EXCLUDED.max_conns`,
+ m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns)
return err
}
@@ -55,9 +66,9 @@ func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClient
m := &XrayClientMeta{}
var expiresAt sql.NullTime
err := s.db.QueryRowContext(ctx, `
- SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at
+ SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at
FROM xray_clients WHERE uuid = $1`, uuid).
- Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt)
+ Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt)
if err != nil {
return nil, err
}
@@ -74,17 +85,49 @@ func (s *Store) DeleteXrayClientMeta(ctx context.Context, uuid string) error {
func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, `
- SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at
+ SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at
FROM xray_clients ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
+ return scanXrayClientMetaRows(rows)
+}
+
+func (s *Store) ListXrayClientsByOwner(ctx context.Context, ownerUsername string) ([]*XrayClientMeta, error) {
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at
+ FROM xray_clients WHERE owner_username = $1 ORDER BY created_at DESC`, ownerUsername)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanXrayClientMetaRows(rows)
+}
+
+func (s *Store) CountXrayClientsByOwner(ctx context.Context, ownerUsername string) (int, error) {
+ var n int
+ err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM xray_clients WHERE owner_username = $1`, ownerUsername).Scan(&n)
+ return n, err
+}
+
+func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT uuid, name, email, inbound_tag, COALESCE(owner_username, ''), expires_at, max_conns, created_at
+ FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanXrayClientMetaRows(rows)
+}
+
+func scanXrayClientMetaRows(rows *sql.Rows) ([]*XrayClientMeta, error) {
var out []*XrayClientMeta
for rows.Next() {
m := &XrayClientMeta{}
var expiresAt sql.NullTime
- if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
+ if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &m.OwnerUsername, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
return nil, err
}
if expiresAt.Valid {
@@ -95,27 +138,49 @@ func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, erro
return out, rows.Err()
}
-func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
- rows, err := s.db.QueryContext(ctx, `
- SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at
- FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`)
+func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int {
+ if store == nil || ownerUsername == "" {
+ return 0
+ }
+ n, err := store.CountXrayClientsByOwner(ctx, ownerUsername)
if err != nil {
- return nil, err
+ log.Printf("count xray clients for %s: %v", ownerUsername, err)
+ return 0
}
- defer rows.Close()
- var out []*XrayClientMeta
- for rows.Next() {
- m := &XrayClientMeta{}
- var expiresAt sql.NullTime
- if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
- return nil, err
- }
- if expiresAt.Valid {
- m.ExpiresAt = &expiresAt.Time
- }
- out = append(out, m)
+ return n
+}
+
+func countOwnedQuota(ctx context.Context, store *Store, ownerUsername string) int {
+ return countOwnedUsers(ownerUsername) + countOwnedXrayClients(ctx, store, ownerUsername)
+}
+
+func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
+ if store == nil || ownerUsername == "" {
+ return
+ }
+ clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
+ if err != nil {
+ log.Printf("xray owner cleanup: list %s: %v", ownerUsername, err)
+ return
+ }
+ needRestart := false
+ for _, m := range clients {
+ if m.InboundTag != "" {
+ if err := xrayMgr.RemoveXrayClient(m.InboundTag, m.UUID); err != nil {
+ log.Printf("xray owner cleanup: remove %s from %s: %v", m.UUID, m.InboundTag, err)
+ } else {
+ needRestart = true
+ }
+ }
+ if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
+ log.Printf("xray owner cleanup: delete meta %s: %v", m.UUID, err)
+ }
+ }
+ if needRestart {
+ if err := xrayMgr.Restart(); err != nil {
+ log.Printf("xray owner cleanup: restart: %v", err)
+ }
}
- return out, rows.Err()
}
// startXrayClientExpiryChecker runs a background goroutine that removes expired
diff --git a/xray_integration.go b/xray_integration.go
index bfdbc9c..af38267 100644
--- a/xray_integration.go
+++ b/xray_integration.go
@@ -1,6 +1,7 @@
package main
import (
+ "context"
"encoding/json"
"fmt"
"io"
@@ -8,6 +9,8 @@ import (
"net/http"
"os"
"os/exec"
+ "regexp"
+ "strconv"
"strings"
"sync"
"syscall"
@@ -19,6 +22,13 @@ type XrayConfig struct {
Enabled bool `json:"enabled"`
BinPath string `json:"bin_path"` // e.g. /opt/sshpanel/xray
ConfigFile string `json:"config_file"` // e.g. /opt/sshpanel/xray_config.json
+
+ // Optional Xray API endpoint used for online client counters. If empty,
+ // the panel auto-detects a local inbound tagged "api" from the Xray config.
+ APIServer string `json:"api_server,omitempty"` // e.g. 127.0.0.1:10085
+ // A client is considered online when its Xray stats traffic changed recently.
+ OnlineWindowSeconds int `json:"online_window_seconds,omitempty"` // default 90
+ StatsPollSeconds int `json:"stats_poll_seconds,omitempty"` // default 15
}
// xrayLogRing is a fixed-capacity circular buffer for captured log lines.
@@ -81,6 +91,24 @@ type XrayManager struct {
cfg *XrayConfig
startTime time.Time
lastErr string
+
+ statsMu sync.RWMutex
+ statsByEmail map[string]xrayRuntimeStat
+ lastStatsErr string
+ lastStatsPoll time.Time
+ pollStarted bool
+}
+
+type xrayTrafficCounters struct {
+ Uplink int64
+ Downlink int64
+}
+
+type xrayRuntimeStat struct {
+ Email string
+ Uplink int64
+ Downlink int64
+ LastActive time.Time
}
var xrayMgr = &XrayManager{}
@@ -93,6 +121,7 @@ func initXrayManager(cfg *XrayConfig) {
xrayMgr.mu.Lock()
xrayMgr.cfg = cfg
xrayMgr.mu.Unlock()
+ xrayMgr.startStatsPoller()
if cfg.Enabled {
if err := xrayMgr.Start(); err != nil {
@@ -129,6 +158,11 @@ func (m *XrayManager) Start() error {
if _, err := os.Stat(m.cfg.BinPath); err != nil {
return fmt.Errorf("xray binary not found at %s", m.cfg.BinPath)
}
+ if changed, err := m.ensureStatsAPIConfigLocked(); err != nil {
+ return fmt.Errorf("xray stats api check failed: %w", err)
+ } else if changed {
+ log.Printf("xray: repaired Stats API support in config before start")
+ }
args := []string{"run"}
if m.cfg.ConfigFile != "" {
@@ -201,18 +235,23 @@ func (m *XrayManager) Restart() error {
// XrayStatusDTO is returned by /api/xray/status.
type XrayStatusDTO struct {
- Enabled bool `json:"enabled"`
- Running bool `json:"running"`
- PID int `json:"pid,omitempty"`
- Uptime string `json:"uptime,omitempty"`
- Error string `json:"error,omitempty"`
+ Enabled bool `json:"enabled"`
+ Running bool `json:"running"`
+ PID int `json:"pid,omitempty"`
+ Uptime string `json:"uptime,omitempty"`
+ Error string `json:"error,omitempty"`
+ OnlineUsers int `json:"online_users"`
+ StatsError string `json:"stats_error,omitempty"`
+ StatsConfigured bool `json:"stats_configured"`
+ StatsMissing []string `json:"stats_missing,omitempty"`
+ APIServer string `json:"api_server,omitempty"`
+ LastStatsPoll *time.Time `json:"last_stats_poll,omitempty"`
+ OnlineWindowSec int `json:"online_window_seconds"`
}
// Status returns a snapshot of the current xray process state.
func (m *XrayManager) Status() XrayStatusDTO {
m.mu.Lock()
- defer m.mu.Unlock()
-
s := XrayStatusDTO{}
if m.cfg != nil {
s.Enabled = m.cfg.Enabled
@@ -225,9 +264,263 @@ func (m *XrayManager) Status() XrayStatusDTO {
if m.lastErr != "" {
s.Error = m.lastErr
}
+ m.mu.Unlock()
+
+ s.OnlineUsers = m.CountOnlineUsers()
+ s.OnlineWindowSec = int(m.onlineWindow().Seconds())
+ m.statsMu.RLock()
+ if m.lastStatsErr != "" {
+ s.StatsError = m.lastStatsErr
+ }
+ if !m.lastStatsPoll.IsZero() {
+ t := m.lastStatsPoll
+ s.LastStatsPoll = &t
+ }
+ m.statsMu.RUnlock()
+ if check, err := m.CheckStatsAPIConfig(); err == nil {
+ s.StatsConfigured = check.Configured
+ s.StatsMissing = check.Missing
+ s.APIServer = check.APIServer
+ } else if err != nil {
+ s.StatsConfigured = false
+ s.StatsMissing = []string{err.Error()}
+ }
return s
}
+func (m *XrayManager) pollInterval() time.Duration {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ sec := 15
+ if m.cfg != nil && m.cfg.StatsPollSeconds > 0 {
+ sec = m.cfg.StatsPollSeconds
+ }
+ if sec < 5 {
+ sec = 5
+ }
+ return time.Duration(sec) * time.Second
+}
+
+func (m *XrayManager) onlineWindow() time.Duration {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ sec := 90
+ if m.cfg != nil && m.cfg.OnlineWindowSeconds > 0 {
+ sec = m.cfg.OnlineWindowSeconds
+ }
+ if sec < 15 {
+ sec = 15
+ }
+ return time.Duration(sec) * time.Second
+}
+
+func (m *XrayManager) startStatsPoller() {
+ m.mu.Lock()
+ if m.pollStarted {
+ m.mu.Unlock()
+ return
+ }
+ m.pollStarted = true
+ m.mu.Unlock()
+
+ go func() {
+ // First sample establishes a baseline. Active Xray users appear online
+ // after their traffic counters change on a later poll.
+ for {
+ m.refreshRuntimeStats()
+ time.Sleep(m.pollInterval())
+ }
+ }()
+}
+
+func (m *XrayManager) isRunningSnapshot() bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.isRunning()
+}
+
+func (m *XrayManager) apiCommandConfig() (binPath, apiServer string, ok bool) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.cfg == nil || m.cfg.BinPath == "" || m.cfg.ConfigFile == "" {
+ return "", "", false
+ }
+ binPath = m.cfg.BinPath
+ apiServer = strings.TrimSpace(m.cfg.APIServer)
+ if apiServer == "" {
+ apiServer = m.discoverAPIServerLocked()
+ }
+ return binPath, apiServer, apiServer != ""
+}
+
+func (m *XrayManager) discoverAPIServerLocked() string {
+ if m.cfg == nil || m.cfg.ConfigFile == "" {
+ return ""
+ }
+ data, err := os.ReadFile(m.cfg.ConfigFile)
+ if err != nil {
+ return ""
+ }
+ var cfg struct {
+ Inbounds []struct {
+ Tag string `json:"tag"`
+ Listen string `json:"listen"`
+ Protocol string `json:"protocol"`
+ Port json.RawMessage `json:"port"`
+ } `json:"inbounds"`
+ }
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return ""
+ }
+ for _, ib := range cfg.Inbounds {
+ if ib.Tag != "api" || !strings.EqualFold(ib.Protocol, "dokodemo-door") {
+ continue
+ }
+ host := strings.TrimSpace(ib.Listen)
+ if host == "" || host == "0.0.0.0" || host == "::" {
+ host = "127.0.0.1"
+ }
+ port := strings.Trim(string(ib.Port), `"`)
+ if port == "" || port == "null" {
+ continue
+ }
+ return host + ":" + port
+ }
+ return ""
+}
+
+func (m *XrayManager) refreshRuntimeStats() {
+ if !m.isRunningSnapshot() {
+ return
+ }
+ traffic, err := m.queryUserTraffic()
+ now := time.Now()
+ m.statsMu.Lock()
+ defer m.statsMu.Unlock()
+ m.lastStatsPoll = now
+ if err != nil {
+ m.lastStatsErr = err.Error()
+ return
+ }
+ m.lastStatsErr = ""
+ if m.statsByEmail == nil {
+ m.statsByEmail = make(map[string]xrayRuntimeStat, len(traffic))
+ }
+ for email, counters := range traffic {
+ prev := m.statsByEmail[email]
+ st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive}
+ if prev.Email != "" && (counters.Uplink != prev.Uplink || counters.Downlink != prev.Downlink) {
+ st.LastActive = now
+ }
+ m.statsByEmail[email] = st
+ }
+}
+
+func (m *XrayManager) queryUserTraffic() (map[string]xrayTrafficCounters, error) {
+ binPath, apiServer, ok := m.apiCommandConfig()
+ if !ok {
+ return nil, fmt.Errorf("Xray API stats not configured; add an api inbound or set xray.api_server")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ attempts := [][]string{
+ {"api", "statsquery", "--server=" + apiServer, "-pattern", "user>>>", "-reset=false"},
+ {"api", "statsquery", "--server", apiServer, "-pattern", "user>>>"},
+ }
+ var lastErr error
+ for _, args := range attempts {
+ cmd := exec.CommandContext(ctx, binPath, args...)
+ out, err := cmd.CombinedOutput()
+ if err == nil {
+ return parseXrayStatsOutput(out), nil
+ }
+ lastErr = fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
+ }
+ return nil, fmt.Errorf("Xray stats query failed: %v", lastErr)
+}
+
+func parseXrayStatsOutput(out []byte) map[string]xrayTrafficCounters {
+ result := map[string]xrayTrafficCounters{}
+ var js struct {
+ Stat []struct {
+ Name string `json:"name"`
+ Value int64 `json:"value"`
+ } `json:"stat"`
+ Stats []struct {
+ Name string `json:"name"`
+ Value int64 `json:"value"`
+ } `json:"stats"`
+ }
+ if json.Unmarshal(out, &js) == nil {
+ for _, st := range js.Stat {
+ addXrayCounter(result, st.Name, st.Value)
+ }
+ for _, st := range js.Stats {
+ addXrayCounter(result, st.Name, st.Value)
+ }
+ }
+ if len(result) > 0 {
+ return result
+ }
+
+ re := regexp.MustCompile(`(?s)name:\s*"([^"]+)"\s+value:\s*([0-9]+)`)
+ for _, m := range re.FindAllSubmatch(out, -1) {
+ v, _ := strconv.ParseInt(string(m[2]), 10, 64)
+ addXrayCounter(result, string(m[1]), v)
+ }
+ return result
+}
+
+func addXrayCounter(result map[string]xrayTrafficCounters, name string, value int64) {
+ parts := strings.Split(name, ">>>")
+ if len(parts) < 5 || parts[0] != "user" || parts[2] != "traffic" {
+ return
+ }
+ email := parts[1]
+ direction := parts[4]
+ if email == "" {
+ return
+ }
+ c := result[email]
+ switch direction {
+ case "uplink":
+ c.Uplink = value
+ case "downlink":
+ c.Downlink = value
+ default:
+ return
+ }
+ result[email] = c
+}
+
+func (m *XrayManager) RuntimeStatsForEmail(email string) (xrayRuntimeStat, bool) {
+ email = strings.TrimSpace(email)
+ if email == "" {
+ return xrayRuntimeStat{}, false
+ }
+ m.statsMu.RLock()
+ st, ok := m.statsByEmail[email]
+ m.statsMu.RUnlock()
+ if !ok || st.Email == "" {
+ return xrayRuntimeStat{}, false
+ }
+ return st, true
+}
+
+func (m *XrayManager) CountOnlineUsers() int {
+ window := m.onlineWindow()
+ now := time.Now()
+ m.statsMu.RLock()
+ defer m.statsMu.RUnlock()
+ n := 0
+ for _, st := range m.statsByEmail {
+ if !st.LastActive.IsZero() && now.Sub(st.LastActive) <= window {
+ n++
+ }
+ }
+ return n
+}
+
// GetConfig reads the current xray JSON config file.
func (m *XrayManager) GetConfig() ([]byte, error) {
m.mu.Lock()
@@ -245,10 +538,362 @@ func (m *XrayManager) SetConfig(data []byte) error {
if m.cfg == nil || m.cfg.ConfigFile == "" {
return fmt.Errorf("xray config file not configured")
}
- if !json.Valid(data) {
- return fmt.Errorf("invalid JSON")
+ patched, changed, err := patchXrayStatsAPIBytes(data)
+ if err != nil {
+ return err
}
- return os.WriteFile(m.cfg.ConfigFile, data, 0o600)
+ if changed {
+ log.Printf("xray: added/repaired Stats API support while saving config")
+ }
+ return os.WriteFile(m.cfg.ConfigFile, patched, 0o600)
+}
+
+type xrayStatsConfigCheck struct {
+ Configured bool
+ Missing []string
+ APIServer string
+}
+
+func (m *XrayManager) CheckStatsAPIConfig() (xrayStatsConfigCheck, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.checkStatsAPIConfigLocked()
+}
+
+func (m *XrayManager) checkStatsAPIConfigLocked() (xrayStatsConfigCheck, error) {
+ if m.cfg == nil || m.cfg.ConfigFile == "" {
+ return xrayStatsConfigCheck{}, fmt.Errorf("xray config file not configured")
+ }
+ data, err := os.ReadFile(m.cfg.ConfigFile)
+ if err != nil {
+ return xrayStatsConfigCheck{}, err
+ }
+ var raw map[string]interface{}
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return xrayStatsConfigCheck{}, fmt.Errorf("parse xray config: %w", err)
+ }
+ if raw == nil {
+ return xrayStatsConfigCheck{}, fmt.Errorf("xray config must be a JSON object")
+ }
+ check := checkXrayStatsAPIConfig(raw)
+ if check.APIServer == "" {
+ check.APIServer = m.discoverAPIServerFromRaw(raw)
+ }
+ return check, nil
+}
+
+func (m *XrayManager) EnsureStatsAPIConfig() (bool, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.ensureStatsAPIConfigLocked()
+}
+
+func (m *XrayManager) ensureStatsAPIConfigLocked() (bool, error) {
+ if m.cfg == nil || m.cfg.ConfigFile == "" {
+ return false, fmt.Errorf("xray config file not configured")
+ }
+ data, err := os.ReadFile(m.cfg.ConfigFile)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return false, err
+ }
+ patched, changed, err := patchXrayStatsAPIBytes(data)
+ if err != nil {
+ return false, err
+ }
+ if !changed {
+ return false, nil
+ }
+ return true, os.WriteFile(m.cfg.ConfigFile, patched, 0o600)
+}
+
+func patchXrayStatsAPIBytes(data []byte) ([]byte, bool, error) {
+ if !json.Valid(data) {
+ return nil, false, fmt.Errorf("invalid JSON")
+ }
+ var raw map[string]interface{}
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return nil, false, fmt.Errorf("parse xray config: %w", err)
+ }
+ if raw == nil {
+ return nil, false, fmt.Errorf("xray config must be a JSON object")
+ }
+ changed, _ := ensureXrayStatsAPIConfig(raw)
+ if !changed {
+ return data, false, nil
+ }
+ out, err := json.MarshalIndent(raw, "", " ")
+ if err != nil {
+ return nil, false, err
+ }
+ return out, true, nil
+}
+
+func ensureXrayStatsAPIConfig(raw map[string]interface{}) (bool, xrayStatsConfigCheck) {
+ changed := false
+
+ api := asObject(raw["api"])
+ if api == nil {
+ api = map[string]interface{}{}
+ raw["api"] = api
+ changed = true
+ }
+ if tag, _ := api["tag"].(string); tag != "api" {
+ api["tag"] = "api"
+ changed = true
+ }
+ services, ok := api["services"].([]interface{})
+ if !ok {
+ services = []interface{}{}
+ }
+ for _, svc := range []string{"HandlerService", "LoggerService", "StatsService"} {
+ if !sliceHasString(services, svc) {
+ services = append(services, svc)
+ changed = true
+ }
+ }
+ api["services"] = services
+
+ if asObject(raw["stats"]) == nil {
+ raw["stats"] = map[string]interface{}{}
+ changed = true
+ }
+
+ policy := asObject(raw["policy"])
+ if policy == nil {
+ policy = map[string]interface{}{}
+ raw["policy"] = policy
+ changed = true
+ }
+ levels := asObject(policy["levels"])
+ if levels == nil {
+ levels = map[string]interface{}{}
+ policy["levels"] = levels
+ changed = true
+ }
+ level0 := asObject(levels["0"])
+ if level0 == nil {
+ level0 = map[string]interface{}{}
+ levels["0"] = level0
+ changed = true
+ }
+ for _, key := range []string{"statsUserUplink", "statsUserDownlink"} {
+ if v, _ := level0[key].(bool); !v {
+ level0[key] = true
+ changed = true
+ }
+ }
+ system := asObject(policy["system"])
+ if system == nil {
+ system = map[string]interface{}{}
+ policy["system"] = system
+ changed = true
+ }
+ for _, key := range []string{"statsInboundUplink", "statsInboundDownlink"} {
+ if v, _ := system[key].(bool); !v {
+ system[key] = true
+ changed = true
+ }
+ }
+
+ inbounds, _ := raw["inbounds"].([]interface{})
+ apiInbound := findObjectByTag(inbounds, "api")
+ if apiInbound == nil {
+ apiInbound = map[string]interface{}{
+ "tag": "api",
+ "listen": "127.0.0.1",
+ "port": float64(10085),
+ "protocol": "dokodemo-door",
+ "settings": map[string]interface{}{"address": "127.0.0.1"},
+ }
+ inbounds = append([]interface{}{apiInbound}, inbounds...)
+ raw["inbounds"] = inbounds
+ changed = true
+ } else {
+ if proto, _ := apiInbound["protocol"].(string); !strings.EqualFold(proto, "dokodemo-door") {
+ apiInbound["protocol"] = "dokodemo-door"
+ changed = true
+ }
+ if strings.TrimSpace(fmt.Sprint(apiInbound["listen"])) == "" {
+ apiInbound["listen"] = "127.0.0.1"
+ changed = true
+ }
+ if _, ok := apiInbound["port"]; !ok || strings.TrimSpace(fmt.Sprint(apiInbound["port"])) == "" || strings.TrimSpace(fmt.Sprint(apiInbound["port"])) == "
" {
+ apiInbound["port"] = float64(10085)
+ changed = true
+ }
+ settings := asObject(apiInbound["settings"])
+ if settings == nil {
+ settings = map[string]interface{}{}
+ apiInbound["settings"] = settings
+ changed = true
+ }
+ if strings.TrimSpace(fmt.Sprint(settings["address"])) == "" || strings.TrimSpace(fmt.Sprint(settings["address"])) == "" {
+ settings["address"] = "127.0.0.1"
+ changed = true
+ }
+ }
+
+ outbounds, _ := raw["outbounds"].([]interface{})
+ if findObjectByTag(outbounds, "api") == nil {
+ outbounds = append(outbounds, map[string]interface{}{"tag": "api", "protocol": "freedom", "settings": map[string]interface{}{}})
+ raw["outbounds"] = outbounds
+ changed = true
+ }
+
+ routing := asObject(raw["routing"])
+ if routing == nil {
+ routing = map[string]interface{}{}
+ raw["routing"] = routing
+ changed = true
+ }
+ rules, _ := routing["rules"].([]interface{})
+ if !hasAPIRoutingRule(rules) {
+ rules = append([]interface{}{map[string]interface{}{"type": "field", "inboundTag": []interface{}{"api"}, "outboundTag": "api"}}, rules...)
+ routing["rules"] = rules
+ changed = true
+ }
+
+ return changed, checkXrayStatsAPIConfig(raw)
+}
+
+func checkXrayStatsAPIConfig(raw map[string]interface{}) xrayStatsConfigCheck {
+ missing := []string{}
+ api := asObject(raw["api"])
+ if api == nil {
+ missing = append(missing, "api.services")
+ } else {
+ services, _ := api["services"].([]interface{})
+ if !sliceHasString(services, "StatsService") {
+ missing = append(missing, "api.services StatsService")
+ }
+ }
+ if asObject(raw["stats"]) == nil {
+ missing = append(missing, "stats")
+ }
+ policy := asObject(raw["policy"])
+ levels := asObject(nil)
+ level0 := asObject(nil)
+ if policy != nil {
+ levels = asObject(policy["levels"])
+ if levels != nil {
+ level0 = asObject(levels["0"])
+ }
+ }
+ if level0 == nil {
+ missing = append(missing, "policy.levels.0")
+ } else {
+ if v, _ := level0["statsUserUplink"].(bool); !v {
+ missing = append(missing, "policy.levels.0.statsUserUplink")
+ }
+ if v, _ := level0["statsUserDownlink"].(bool); !v {
+ missing = append(missing, "policy.levels.0.statsUserDownlink")
+ }
+ }
+
+ inbounds, _ := raw["inbounds"].([]interface{})
+ apiInbound := findObjectByTag(inbounds, "api")
+ if apiInbound == nil {
+ missing = append(missing, "api inbound")
+ }
+ outbounds, _ := raw["outbounds"].([]interface{})
+ if findObjectByTag(outbounds, "api") == nil {
+ missing = append(missing, "api outbound")
+ }
+ routing := asObject(raw["routing"])
+ if routing == nil {
+ missing = append(missing, "routing api rule")
+ } else {
+ rules, _ := routing["rules"].([]interface{})
+ if !hasAPIRoutingRule(rules) {
+ missing = append(missing, "routing api rule")
+ }
+ }
+ return xrayStatsConfigCheck{Configured: len(missing) == 0, Missing: missing, APIServer: discoverAPIServerFromRaw(raw)}
+}
+
+func (m *XrayManager) discoverAPIServerFromRaw(raw map[string]interface{}) string {
+ return discoverAPIServerFromRaw(raw)
+}
+
+func discoverAPIServerFromRaw(raw map[string]interface{}) string {
+ inbounds, _ := raw["inbounds"].([]interface{})
+ for _, item := range inbounds {
+ ib, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ tag, _ := ib["tag"].(string)
+ proto, _ := ib["protocol"].(string)
+ if tag != "api" || !strings.EqualFold(proto, "dokodemo-door") {
+ continue
+ }
+ host, _ := ib["listen"].(string)
+ host = strings.TrimSpace(host)
+ if host == "" || host == "0.0.0.0" || host == "::" {
+ host = "127.0.0.1"
+ }
+ port := strings.TrimSpace(fmt.Sprint(ib["port"]))
+ port = strings.Trim(port, `"`)
+ if port == "" || port == "" || port == "null" {
+ continue
+ }
+ return host + ":" + port
+ }
+ return ""
+}
+
+func asObject(v interface{}) map[string]interface{} {
+ m, _ := v.(map[string]interface{})
+ return m
+}
+
+func findObjectByTag(items []interface{}, tag string) map[string]interface{} {
+ for _, item := range items {
+ m, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ if t, _ := m["tag"].(string); t == tag {
+ return m
+ }
+ }
+ return nil
+}
+
+func sliceHasString(items []interface{}, want string) bool {
+ for _, item := range items {
+ if s, ok := item.(string); ok && strings.EqualFold(s, want) {
+ return true
+ }
+ }
+ return false
+}
+
+func hasAPIRoutingRule(rules []interface{}) bool {
+ for _, item := range rules {
+ rule, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ outbound, _ := rule["outboundTag"].(string)
+ if outbound != "api" {
+ continue
+ }
+ tags, ok := rule["inboundTag"].([]interface{})
+ if !ok {
+ if tag, _ := rule["inboundTag"].(string); tag == "api" {
+ return true
+ }
+ continue
+ }
+ if sliceHasString(tags, "api") {
+ return true
+ }
+ }
+ return false
}
// ---- Admin HTTP handlers ----
@@ -258,8 +903,23 @@ func handleXrayStatus(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
+ status := xrayMgr.Status()
+ if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && statsStore != nil {
+ metas, err := statsStore.ListXrayClientsByOwner(r.Context(), sess.Username)
+ if err == nil {
+ status.OnlineUsers = 0
+ window := xrayMgr.onlineWindow()
+ now := time.Now()
+ for _, m := range metas {
+ st, ok := xrayMgr.RuntimeStatsForEmail(m.Email)
+ if ok && !st.LastActive.IsZero() && now.Sub(st.LastActive) <= window {
+ status.OnlineUsers++
+ }
+ }
+ }
+ }
w.Header().Set("Content-Type", "application/json")
- _ = json.NewEncoder(w).Encode(xrayMgr.Status())
+ _ = json.NewEncoder(w).Encode(status)
}
func handleXrayStart(w http.ResponseWriter, r *http.Request) {
@@ -326,6 +986,36 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
}
}
+func handleXrayRepairStats(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ wasRunning := xrayMgr.isRunningSnapshot()
+ changed, err := xrayMgr.EnsureStatsAPIConfig()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ restarted := false
+ if wasRunning {
+ if err := xrayMgr.Restart(); err != nil {
+ http.Error(w, "config repaired but restart failed: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ restarted = true
+ }
+ check, _ := xrayMgr.CheckStatsAPIConfig()
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{
+ "changed": changed,
+ "restarted": restarted,
+ "stats_configured": check.Configured,
+ "stats_missing": check.Missing,
+ "api_server": check.APIServer,
+ })
+}
+
func handleXrayLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
@@ -346,11 +1036,19 @@ type XrayClientInfo struct {
UUID string `json:"id"`
Email string `json:"email"`
Level int `json:"level,omitempty"`
+ // Runtime counters from the Xray stats API. Online means this user's
+ // traffic counters changed inside the configured online window.
+ Online bool `json:"online"`
+ LastActive *time.Time `json:"last_active,omitempty"`
+ UplinkBytes int64 `json:"uplink_bytes,omitempty"`
+ DownlinkBytes int64 `json:"downlink_bytes,omitempty"`
+ TotalBytes int64 `json:"total_bytes,omitempty"`
// Metadata from PostgreSQL (enriched by handleXrayInbounds)
Name string `json:"name,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
ExpirationDays int `json:"expiration_days"`
MaxConns int `json:"max_conns"`
+ OwnerUsername string `json:"owner_username,omitempty"`
Expired bool `json:"expired,omitempty"`
}
@@ -434,6 +1132,9 @@ func (m *XrayManager) modifyRawConfig(fn func(cfg map[string]interface{}) error)
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("parse xray config: %w", err)
}
+ if raw == nil {
+ return fmt.Errorf("xray config must be a JSON object")
+ }
if err := fn(raw); err != nil {
return err
}
@@ -449,6 +1150,7 @@ func (m *XrayManager) AddXrayClient(inboundTag, uuid, email string) error {
m.mu.Lock()
defer m.mu.Unlock()
return m.modifyRawConfig(func(raw map[string]interface{}) error {
+ _, _ = ensureXrayStatsAPIConfig(raw)
inbounds, _ := raw["inbounds"].([]interface{})
for _, ib := range inbounds {
ibMap, ok := ib.(map[string]interface{})
@@ -537,26 +1239,51 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- // Enrich clients with metadata from PostgreSQL when available.
+
+ sess := sessionFromCtx(r.Context())
+ isReseller := sess != nil && sess.Role == RoleReseller
+
+ // Enrich clients with metadata from PostgreSQL when available. Resellers only
+ // see their own Xray clients, but they still see all available inbounds so
+ // they know where they can create new users.
if statsStore != nil {
metas, err := statsStore.ListAllXrayClients(r.Context())
- if err == nil {
+ if err != nil {
+ if isReseller {
+ for i := range inbounds {
+ inbounds[i].Clients = []XrayClientInfo{}
+ }
+ } else {
+ for i := range inbounds {
+ for j := range inbounds[i].Clients {
+ applyXrayRuntimeStats(&inbounds[i].Clients[j])
+ }
+ }
+ }
+ } else {
metaMap := make(map[string]*XrayClientMeta, len(metas))
for _, m := range metas {
metaMap[m.UUID] = m
}
now := time.Now()
for i := range inbounds {
+ filtered := make([]XrayClientInfo, 0, len(inbounds[i].Clients))
for j := range inbounds[i].Clients {
- c := &inbounds[i].Clients[j]
+ c := inbounds[i].Clients[j]
+ applyXrayRuntimeStats(&c)
m, ok := metaMap[c.UUID]
+ if isReseller && (!ok || m.OwnerUsername != sess.Username) {
+ continue
+ }
if !ok {
c.ExpirationDays = -1
+ filtered = append(filtered, c)
continue
}
c.Name = m.Name
c.ExpiresAt = m.ExpiresAt
c.MaxConns = m.MaxConns
+ c.OwnerUsername = m.OwnerUsername
if m.ExpiresAt == nil {
c.ExpirationDays = -1
} else if m.ExpiresAt.Before(now) {
@@ -565,7 +1292,19 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
} else {
c.ExpirationDays = int(m.ExpiresAt.Sub(now).Hours() / 24)
}
+ filtered = append(filtered, c)
}
+ inbounds[i].Clients = filtered
+ }
+ }
+ } else if isReseller {
+ for i := range inbounds {
+ inbounds[i].Clients = []XrayClientInfo{}
+ }
+ } else {
+ for i := range inbounds {
+ for j := range inbounds[i].Clients {
+ applyXrayRuntimeStats(&inbounds[i].Clients[j])
}
}
}
@@ -573,6 +1312,26 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(inbounds)
}
+func applyXrayRuntimeStats(c *XrayClientInfo) {
+ if c == nil {
+ return
+ }
+ st, ok := xrayMgr.RuntimeStatsForEmail(c.Email)
+ if !ok {
+ return
+ }
+ window := xrayMgr.onlineWindow()
+ now := time.Now()
+ c.UplinkBytes = st.Uplink
+ c.DownlinkBytes = st.Downlink
+ c.TotalBytes = st.Uplink + st.Downlink
+ if !st.LastActive.IsZero() {
+ t := st.LastActive
+ c.LastActive = &t
+ c.Online = now.Sub(st.LastActive) <= window
+ }
+}
+
func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
@@ -594,17 +1353,45 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
return
}
+ req.Email = strings.TrimSpace(req.Email)
+ if req.Email == "" {
+ req.Email = strings.TrimSpace(req.Name)
+ }
+ if req.Email == "" {
+ req.Email = req.UUID
+ }
+
+ sess := sessionFromCtx(r.Context())
+ ownerUsername := ""
+ if sess != nil && sess.Role == RoleReseller {
+ ownerUsername = sess.Username
+ if statsStore == nil {
+ http.Error(w, "storage not available", http.StatusInternalServerError)
+ return
+ }
+ owner, ok := adminUsers.get(sess.Username)
+ if !ok || !owner.IsActive || (owner.ExpiresAt != nil && time.Now().After(*owner.ExpiresAt)) {
+ http.Error(w, "reseller account suspended or expired", http.StatusForbidden)
+ return
+ }
+ if owner.MaxUsers > 0 && countOwnedQuota(r.Context(), statsStore, sess.Username) >= owner.MaxUsers {
+ http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
+ return
+ }
+ }
+
if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if statsStore != nil {
meta := XrayClientMeta{
- UUID: req.UUID,
- Name: req.Name,
- Email: req.Email,
- InboundTag: req.InboundTag,
- MaxConns: req.MaxConnections,
+ UUID: req.UUID,
+ Name: req.Name,
+ Email: req.Email,
+ InboundTag: req.InboundTag,
+ OwnerUsername: ownerUsername,
+ MaxConns: req.MaxConnections,
}
if req.ExpiresAt != "" {
var t time.Time
@@ -653,11 +1440,25 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
http.Error(w, "storage not available", http.StatusInternalServerError)
return
}
+
+ existing, err := statsStore.GetXrayClientMeta(r.Context(), req.UUID)
+ if err != nil {
+ http.Error(w, "client metadata not found", http.StatusNotFound)
+ return
+ }
+ sess := sessionFromCtx(r.Context())
+ if sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+
meta := XrayClientMeta{
- UUID: req.UUID,
- Name: req.Name,
- Email: req.Email,
- MaxConns: req.MaxConnections,
+ UUID: req.UUID,
+ Name: req.Name,
+ Email: req.Email,
+ InboundTag: existing.InboundTag,
+ OwnerUsername: existing.OwnerUsername,
+ MaxConns: req.MaxConnections,
}
if req.ExpiresAt != "" {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
@@ -685,6 +1486,23 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
return
}
+
+ sess := sessionFromCtx(r.Context())
+ if sess != nil && sess.Role == RoleReseller {
+ if statsStore == nil {
+ http.Error(w, "storage not available", http.StatusInternalServerError)
+ return
+ }
+ meta, err := statsStore.GetXrayClientMeta(r.Context(), uuid)
+ if err != nil || meta.OwnerUsername != sess.Username {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ if meta.InboundTag != "" {
+ inboundTag = meta.InboundTag
+ }
+ }
+
if err := xrayMgr.RemoveXrayClient(inboundTag, uuid); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return