Panel Update

This commit is contained in:
2026-05-10 17:52:36 -03:00
parent 51aedfd3c7
commit 03c43debf4
8 changed files with 3408 additions and 1819 deletions

300
admin/assets/app.css Normal file
View File

@@ -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;}

1847
admin/assets/app.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
// ─── State ─────────────────────────────────────────────────────────────────── // ─── State ───────────────────────────────────────────────────────────────────
let sessionToken = localStorage.getItem("SESSION_TOKEN") || ""; let sessionToken = localStorage.getItem("SESSION_TOKEN") || "";
let currentRole = ""; let currentRole = "";
@@ -18,6 +19,26 @@ const mainApp = document.getElementById("mainApp");
const meUsername = document.getElementById("meUsername"); const meUsername = document.getElementById("meUsername");
const roleChip = document.getElementById("roleChip"); const roleChip = document.getElementById("roleChip");
const logoutBtn = document.getElementById("logoutBtn"); 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 // Users
const usersBody = document.getElementById("usersBody"); const usersBody = document.getElementById("usersBody");
@@ -153,15 +174,48 @@ function genUUID() {
(c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16)); (c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16));
} }
// ─── Tab switching ──────────────────────────────────────────────────────────── // ─── Navigation / shell ──────────────────────────────────────────────────────
document.querySelectorAll(".tab-btn").forEach(btn => { const tabTitles = {
btn.addEventListener("click", () => { dashboard: ["Painel", "Visão geral"],
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); ssh: ["Contas", "SSH / SlowDNS"],
document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active")); xray: ["Contas", "Xray Users"],
btn.classList.add("active"); resellers: ["Administração", "Revendedores"],
document.getElementById("tab-" + btn.dataset.tab).classList.add("active"); 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 ─────────────────────────────────────────────────────────── // ─── Login / Logout ───────────────────────────────────────────────────────────
loginBtn.addEventListener("click", doLogin); loginBtn.addEventListener("click", doLogin);
@@ -215,31 +269,34 @@ function clearTimers() {
function initAfterLogin() { function initAfterLogin() {
meUsername.textContent = currentUser; meUsername.textContent = currentUser;
if (sidebarUsername) sidebarUsername.textContent = currentUser;
if (sidebarRole) sidebarRole.textContent = currentRole === "superadmin" ? "Super Admin" : "Revendedor";
roleChip.innerHTML = currentRole === "superadmin" roleChip.innerHTML = currentRole === "superadmin"
? `<span class="chip green">superadmin</span>` ? `<span class="chip green">superadmin</span>`
: `<span class="chip warn">reseller</span>`; : `<span class="chip warn">reseller</span>`;
// Show/hide superadmin-only elements
document.querySelectorAll(".superadmin-only").forEach(el => { document.querySelectorAll(".superadmin-only").forEach(el => {
el.classList.toggle("hidden", currentRole !== "superadmin"); el.classList.toggle("hidden", currentRole !== "superadmin");
}); });
document.querySelectorAll(".xray-admin-only").forEach(el => {
el.classList.toggle("hidden", currentRole !== "superadmin");
});
// Reset to SSH tab resellerInfoCard.classList.toggle("hidden", currentRole !== "reseller");
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller");
document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
document.querySelector("[data-tab='ssh']").classList.add("active"); selectTab("dashboard");
document.getElementById("tab-ssh").classList.add("active");
if (currentRole === "superadmin") { if (currentRole === "superadmin") {
loadStats(); loadStats();
statsTimer = setInterval(loadStats, 2000); statsTimer = setInterval(loadStats, 2000);
xrayTimer = setInterval(loadXrayStatus, 5000);
} else { } else {
resellerInfoCard.classList.remove("hidden");
loadMe(); loadMe();
} }
xrayTimer = setInterval(loadXrayStatus, 7000);
loadUsers(); loadUsers();
loadInbounds();
usersTimer = setInterval(() => loadUsersSilent(), 3000); usersTimer = setInterval(() => loadUsersSilent(), 3000);
} }
@@ -248,13 +305,56 @@ async function loadMe() {
try { try {
const res = await api("/api/auth/me"); const res = await api("/api/auth/me");
const d = await res.json(); 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"; rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : "No limit";
rStatus.textContent = d.is_active ? "Active" : "Suspended"; rStatus.textContent = d.is_active ? "Active" : "Suspended";
rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)"; rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)";
updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0);
} catch {} } 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 ──────────────────────────────────────────────────────────────── // ─── SSH Users ────────────────────────────────────────────────────────────────
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers); document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
newUserBtn.addEventListener("click", () => { newUserBtn.addEventListener("click", () => {
@@ -304,6 +404,7 @@ async function loadUsersSilent() {
} }
function renderUsers(users) { function renderUsers(users) {
updateDashboardFromUsers(users);
const isSA = currentRole === "superadmin"; const isSA = currentRole === "superadmin";
userCountChip.textContent = users.length; userCountChip.textContent = users.length;
if (isSA) ownerColHead.classList.remove("hidden"); if (isSA) ownerColHead.classList.remove("hidden");
@@ -418,7 +519,9 @@ document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs); document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
document.querySelector("[data-tab='xray']")?.addEventListener("click", () => { document.querySelector("[data-tab='xray']")?.addEventListener("click", () => {
loadXrayStatus(); loadInbounds(); loadWizardFromConfig(); loadXrayStatus();
loadInbounds();
if (currentRole === "superadmin") loadWizardFromConfig();
}); });
async function loadXrayStatus() { async function loadXrayStatus() {
@@ -432,6 +535,9 @@ async function loadXrayStatus() {
xRunning.style.color = run ? "var(--success)" : "var(--danger)"; xRunning.style.color = run ? "var(--success)" : "var(--danger)";
xPID.textContent = s.pid || "--"; xPID.textContent = s.pid || "--";
xUptime.textContent = s.uptime || "--"; 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; if (s.error) xStatus.textContent = "Error: " + s.error;
} catch (e) { if (e.message==="auth") doAuthError(); } } catch (e) { if (e.message==="auth") doAuthError(); }
} }
@@ -463,6 +569,7 @@ async function loadInbounds() {
} }
function renderInbounds(inbounds) { function renderInbounds(inbounds) {
updateDashboardXray(inbounds);
if (!inbounds.length) { if (!inbounds.length) {
inboundsContainer.innerHTML = '<div class="hint" style="padding:8px 0;">No VLESS/VMess/Trojan inbounds found.</div>'; inboundsContainer.innerHTML = '<div class="hint" style="padding:8px 0;">No VLESS/VMess/Trojan inbounds found.</div>';
return; return;
@@ -694,7 +801,7 @@ function renderResellers(list) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.innerHTML = ` tr.innerHTML = `
<td>${r.username}</td> <td>${r.username}</td>
<td>${r.used_users} / ${r.max_users || "∞"}</td> <td>${r.used_users} / ${r.max_users || "∞"}<div class="hint">SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}</div></td>
<td>${r.expires_at ? fmtDate(r.expires_at) : "—"}</td> <td>${r.expires_at ? fmtDate(r.expires_at) : "—"}</td>
<td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td> <td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td>
<td></td>`; <td></td>`;

20
auth.go
View File

@@ -443,6 +443,7 @@ func startResellerExpiryChecker(store *Store) {
u.IsActive = false u.IsActive = false
adminUsers.set(u) adminUsers.set(u)
disconnectOwnerUsers(u.Username) disconnectOwnerUsers(u.Username)
removeOwnerXrayClients(ctx, store, u.Username)
} }
// Reactivate resellers that have been renewed (inactive but expiry now in future/nil) // 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 s.Role == RoleReseller {
if u, ok := adminUsers.get(s.Username); ok { if u, ok := adminUsers.get(s.Username); ok {
resp["max_users"] = u.MaxUsers 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["expires_at"] = u.ExpiresAt
resp["is_active"] = u.IsActive resp["is_active"] = u.IsActive
} }
@@ -554,6 +557,8 @@ type ResellerDTO struct {
Role string `json:"role"` Role string `json:"role"`
MaxUsers int `json:"max_users"` MaxUsers int `json:"max_users"`
UsedUsers int `json:"used_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"` ExpiresAt *time.Time `json:"expires_at,omitempty"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -577,7 +582,9 @@ func handleListResellers(store *Store) http.HandlerFunc {
Username: u.Username, Username: u.Username,
Role: u.Role, Role: u.Role,
MaxUsers: u.MaxUsers, 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, ExpiresAt: u.ExpiresAt,
IsActive: u.IsActive, IsActive: u.IsActive,
CreatedAt: u.CreatedAt, CreatedAt: u.CreatedAt,
@@ -652,8 +659,12 @@ func handleCreateReseller(store *Store) http.HandlerFunc {
} }
adminUsers.set(u) adminUsers.set(u)
// If reseller was reactivated, users can reconnect automatically. if u.Role == RoleReseller {
// Reconnect of existing SSH connections happens via the expiry checker. if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
disconnectOwnerUsers(u.Username)
removeOwnerXrayClients(ctx, store, u.Username)
}
}
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
} }
@@ -676,6 +687,7 @@ func handleDeleteReseller(store *Store) http.HandlerFunc {
return return
} }
disconnectOwnerUsers(username) disconnectOwnerUsers(username)
removeOwnerXrayClients(ctx, store, username)
adminUsers.delete(username) adminUsers.delete(username)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }

16
main.go
View File

@@ -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/create", saSession(http.HandlerFunc(handleCreateReseller(store))))
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store)))) mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store))))
// Superadmin-only: Xray-core management // Xray-core management. Service/config/log actions are superadmin-only;
mux.Handle("/api/xray/status", saSession(http.HandlerFunc(handleXrayStatus))) // 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/start", saSession(http.HandlerFunc(handleXrayStart)))
mux.Handle("/api/xray/stop", saSession(http.HandlerFunc(handleXrayStop))) mux.Handle("/api/xray/stop", saSession(http.HandlerFunc(handleXrayStop)))
mux.Handle("/api/xray/restart", saSession(http.HandlerFunc(handleXrayRestart))) 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/config", saSession(http.HandlerFunc(handleXrayConfig)))
mux.Handle("/api/xray/logs", saSession(http.HandlerFunc(handleXrayLogs))) mux.Handle("/api/xray/logs", saSession(http.HandlerFunc(handleXrayLogs)))
mux.Handle("/api/xray/inbounds", saSession(http.HandlerFunc(handleXrayInbounds))) mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
mux.Handle("/api/xray/clients/add", saSession(http.HandlerFunc(handleXrayClientAdd))) mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
mux.Handle("/api/xray/clients/update", saSession(http.HandlerFunc(handleXrayClientUpdate))) mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
mux.Handle("/api/xray/clients/remove", saSession(http.HandlerFunc(handleXrayClientRemove))) mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
// Superadmin-only: TLS certificate generation // Superadmin-only: TLS certificate generation
mux.Handle("/api/tls/generate-selfsigned", saSession(http.HandlerFunc(handleTLSGenerateSelfSigned))) mux.Handle("/api/tls/generate-selfsigned", saSession(http.HandlerFunc(handleTLSGenerateSelfSigned)))
@@ -1508,7 +1510,7 @@ func handleCreateUser(store *Store) http.HandlerFunc {
).Scan(&existsInDB) ).Scan(&existsInDB)
if !existsInDB { if !existsInDB {
owner, ok := adminUsers.get(sess.Username) 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) http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
return return
} }

View File

@@ -8,29 +8,39 @@ import (
) )
// XrayClientMeta holds metadata stored in PostgreSQL for an Xray client. // 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 { type XrayClientMeta struct {
UUID string UUID string
Name string Name string
Email string Email string
InboundTag string InboundTag string
ExpiresAt *time.Time OwnerUsername string
MaxConns int ExpiresAt *time.Time
CreatedAt time.Time MaxConns int
CreatedAt time.Time
} }
func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error { func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, ` stmts := []string{
CREATE TABLE IF NOT EXISTS xray_clients ( `CREATE TABLE IF NOT EXISTS xray_clients (
uuid TEXT PRIMARY KEY, uuid TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
inbound_tag TEXT NOT NULL DEFAULT '', inbound_tag TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ, owner_username TEXT NOT NULL DEFAULT '',
max_conns INT NOT NULL DEFAULT 0, expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() max_conns INT NOT NULL DEFAULT 0,
)`) created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
return err )`,
`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 { 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 expiresAt = *m.ExpiresAt
} }
_, err := s.db.ExecContext(ctx, ` _, err := s.db.ExecContext(ctx, `
INSERT INTO xray_clients (uuid, name, email, inbound_tag, expires_at, max_conns) INSERT INTO xray_clients (uuid, name, email, inbound_tag, owner_username, expires_at, max_conns)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (uuid) DO UPDATE SET ON CONFLICT (uuid) DO UPDATE SET
name = EXCLUDED.name, name = EXCLUDED.name,
email = EXCLUDED.email, email = EXCLUDED.email,
inbound_tag = EXCLUDED.inbound_tag, inbound_tag = CASE WHEN EXCLUDED.inbound_tag <> '' THEN EXCLUDED.inbound_tag ELSE xray_clients.inbound_tag END,
expires_at = EXCLUDED.expires_at, owner_username = CASE WHEN EXCLUDED.owner_username <> '' THEN EXCLUDED.owner_username ELSE xray_clients.owner_username END,
max_conns = EXCLUDED.max_conns`, expires_at = EXCLUDED.expires_at,
m.UUID, m.Name, m.Email, m.InboundTag, expiresAt, m.MaxConns) max_conns = EXCLUDED.max_conns`,
m.UUID, m.Name, m.Email, m.InboundTag, m.OwnerUsername, expiresAt, m.MaxConns)
return err return err
} }
@@ -55,9 +66,9 @@ func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClient
m := &XrayClientMeta{} m := &XrayClientMeta{}
var expiresAt sql.NullTime var expiresAt sql.NullTime
err := s.db.QueryRowContext(ctx, ` 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). 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 { if err != nil {
return nil, err 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) { func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
rows, err := s.db.QueryContext(ctx, ` 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`) FROM xray_clients ORDER BY created_at DESC`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() 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 var out []*XrayClientMeta
for rows.Next() { for rows.Next() {
m := &XrayClientMeta{} m := &XrayClientMeta{}
var expiresAt sql.NullTime 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 return nil, err
} }
if expiresAt.Valid { if expiresAt.Valid {
@@ -95,27 +138,49 @@ func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, erro
return out, rows.Err() return out, rows.Err()
} }
func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) { func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername string) int {
rows, err := s.db.QueryContext(ctx, ` if store == nil || ownerUsername == "" {
SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at return 0
FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`) }
n, err := store.CountXrayClientsByOwner(ctx, ownerUsername)
if err != nil { if err != nil {
return nil, err log.Printf("count xray clients for %s: %v", ownerUsername, err)
return 0
} }
defer rows.Close() return n
var out []*XrayClientMeta }
for rows.Next() {
m := &XrayClientMeta{} func countOwnedQuota(ctx context.Context, store *Store, ownerUsername string) int {
var expiresAt sql.NullTime return countOwnedUsers(ownerUsername) + countOwnedXrayClients(ctx, store, ownerUsername)
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil { }
return nil, err
} func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
if expiresAt.Valid { if store == nil || ownerUsername == "" {
m.ExpiresAt = &expiresAt.Time return
} }
out = append(out, m) 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 // startXrayClientExpiryChecker runs a background goroutine that removes expired

File diff suppressed because it is too large Load Diff