Fix Admin panel and xray count
This commit is contained in:
@@ -298,3 +298,32 @@ pre.log-box{background:#121214;border-color:var(--border);border-radius:15px;}
|
|||||||
@media(max-width:620px){.mini-summary{grid-template-columns:1fr}.mini-meter{max-width:none}}
|
@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{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;}
|
.table-meter span{display:block;height:100%;background:linear-gradient(90deg,#36d37a,#ffbe4c,#ff7070);border-radius:inherit;}
|
||||||
|
|
||||||
|
/* Final responsive hardening */
|
||||||
|
.card-hdr{align-items:flex-start;max-width:100%;}
|
||||||
|
.card-hdr .card-title{flex:1 1 220px;min-width:0;}
|
||||||
|
.card-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;max-width:100%;min-width:0;}
|
||||||
|
.card-actions .btn{white-space:nowrap;}
|
||||||
|
.dashboard-meter{max-width:230px;}
|
||||||
|
.save-bar{margin-top:18px;padding:14px 16px;border:1px solid var(--border);border-radius:18px;background:linear-gradient(180deg,var(--card2),var(--card));box-shadow:var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}
|
||||||
|
.save-bar-actions{justify-content:flex-start;}
|
||||||
|
.topbar-actions{min-width:0;}
|
||||||
|
.welcome-actions .btn{white-space:nowrap;}
|
||||||
|
@media(max-width:820px){
|
||||||
|
.card-hdr .card-title{flex-basis:100%;}
|
||||||
|
.card-actions{width:100%;justify-content:flex-start;}
|
||||||
|
.card-actions .btn{flex:1 1 auto;justify-content:center;}
|
||||||
|
.save-bar{align-items:stretch;}
|
||||||
|
.save-bar-actions{width:100%;}
|
||||||
|
.save-bar-actions .btn{flex:1 1 130px;}
|
||||||
|
.welcome-actions{width:100%;justify-content:flex-start;}
|
||||||
|
.welcome-actions .btn{flex:1 1 150px;justify-content:center;}
|
||||||
|
}
|
||||||
|
@media(max-width:420px){
|
||||||
|
.topbar-left{min-width:0;}
|
||||||
|
.workspace-main{padding-left:10px;padding-right:10px;}
|
||||||
|
.dash-card{min-height:132px;padding:18px;}
|
||||||
|
.dash-card small{font-size:.78rem;}
|
||||||
|
.dash-icon{width:54px;height:54px;font-size:1.35rem;}
|
||||||
|
.btn{padding:9px 11px;}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ const dashServers = document.getElementById("dashServers");
|
|||||||
const dashServerStatus = document.getElementById("dashServerStatus");
|
const dashServerStatus = document.getElementById("dashServerStatus");
|
||||||
const dashXrayClients = document.getElementById("dashXrayClients");
|
const dashXrayClients = document.getElementById("dashXrayClients");
|
||||||
const dashXrayStatus = document.getElementById("dashXrayStatus");
|
const dashXrayStatus = document.getElementById("dashXrayStatus");
|
||||||
|
const dashCpuVal = document.getElementById("dashCpuVal");
|
||||||
|
const dashCpuText = document.getElementById("dashCpuText");
|
||||||
|
const dashCpuBar = document.getElementById("dashCpuBar");
|
||||||
|
const dashRamVal = document.getElementById("dashRamVal");
|
||||||
|
const dashRamText = document.getElementById("dashRamText");
|
||||||
|
const dashRamBar = document.getElementById("dashRamBar");
|
||||||
|
const dashNetVal = document.getElementById("dashNetVal");
|
||||||
|
const dashNetText = document.getElementById("dashNetText");
|
||||||
|
const dashNetTotal = document.getElementById("dashNetTotal");
|
||||||
const dashQuotaChip = document.getElementById("dashQuotaChip");
|
const dashQuotaChip = document.getElementById("dashQuotaChip");
|
||||||
const dashQuotaBar = document.getElementById("dashQuotaBar");
|
const dashQuotaBar = document.getElementById("dashQuotaBar");
|
||||||
const dashQuotaText = document.getElementById("dashQuotaText");
|
const dashQuotaText = document.getElementById("dashQuotaText");
|
||||||
@@ -85,6 +94,7 @@ const xRunning = document.getElementById("xRunning");
|
|||||||
const xPID = document.getElementById("xPID");
|
const xPID = document.getElementById("xPID");
|
||||||
const xUptime = document.getElementById("xUptime");
|
const xUptime = document.getElementById("xUptime");
|
||||||
const xStatus = document.getElementById("xStatus");
|
const xStatus = document.getElementById("xStatus");
|
||||||
|
const xOnlineUsers = document.getElementById("xOnlineUsers");
|
||||||
const xCfgEditor = document.getElementById("xCfgEditor");
|
const xCfgEditor = document.getElementById("xCfgEditor");
|
||||||
const xCfgStatus = document.getElementById("xCfgStatus");
|
const xCfgStatus = document.getElementById("xCfgStatus");
|
||||||
const xLogsBox = document.getElementById("xLogsBox");
|
const xLogsBox = document.getElementById("xLogsBox");
|
||||||
@@ -468,6 +478,7 @@ function refreshDashboard() {
|
|||||||
loadUsersSilent();
|
loadUsersSilent();
|
||||||
loadInbounds();
|
loadInbounds();
|
||||||
loadXrayStatus();
|
loadXrayStatus();
|
||||||
|
if (currentRole === "superadmin") loadStats();
|
||||||
if (currentRole === "reseller") loadMe();
|
if (currentRole === "reseller") loadMe();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,7 +645,7 @@ document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("s
|
|||||||
document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop"));
|
document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop"));
|
||||||
document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart"));
|
document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart"));
|
||||||
document.getElementById("xRepairStatsBtn")?.addEventListener("click", repairXrayStats);
|
document.getElementById("xRepairStatsBtn")?.addEventListener("click", repairXrayStats);
|
||||||
document.getElementById("xRefreshBtn").addEventListener("click", loadXrayStatus);
|
document.getElementById("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds(); });
|
||||||
document.getElementById("xLoadInboundsBtn").addEventListener("click", loadInbounds);
|
document.getElementById("xLoadInboundsBtn").addEventListener("click", loadInbounds);
|
||||||
document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
|
document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
|
||||||
document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
|
document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
|
||||||
@@ -727,6 +738,24 @@ async function loadInbounds() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyText(text) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.left = "-9999px";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
try { return document.execCommand("copy"); }
|
||||||
|
finally { document.body.removeChild(ta); }
|
||||||
|
}
|
||||||
|
|
||||||
function renderInbounds(inbounds) {
|
function renderInbounds(inbounds) {
|
||||||
updateDashboardXray(inbounds);
|
updateDashboardXray(inbounds);
|
||||||
if (!inbounds.length) {
|
if (!inbounds.length) {
|
||||||
@@ -815,7 +844,7 @@ function renderInbounds(inbounds) {
|
|||||||
const copyBtn = document.createElement("button");
|
const copyBtn = document.createElement("button");
|
||||||
copyBtn.className = "btn btn-ghost btn-sm";
|
copyBtn.className = "btn btn-ghost btn-sm";
|
||||||
copyBtn.textContent = "Copy";
|
copyBtn.textContent = "Copy";
|
||||||
copyBtn.onclick = () => navigator.clipboard.writeText(c.id);
|
copyBtn.onclick = async () => { await copyText(c.id); xStatus.textContent = "Copied client ID."; };
|
||||||
const editBtn = document.createElement("button");
|
const editBtn = document.createElement("button");
|
||||||
editBtn.className = "btn btn-warn btn-sm";
|
editBtn.className = "btn btn-warn btn-sm";
|
||||||
editBtn.style.marginLeft = "4px";
|
editBtn.style.marginLeft = "4px";
|
||||||
@@ -1043,10 +1072,37 @@ async function deleteReseller(username) {
|
|||||||
// ─── Stats ────────────────────────────────────────────────────────────────────
|
// ─── Stats ────────────────────────────────────────────────────────────────────
|
||||||
document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats);
|
document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats);
|
||||||
|
|
||||||
|
function updateDashboardStats(s) {
|
||||||
|
if (!s) return;
|
||||||
|
const cpu = Number(s.cpu_percent ?? 0);
|
||||||
|
const mem = s.mem_percent == null ? null : Number(s.mem_percent);
|
||||||
|
if (dashCpuVal) dashCpuVal.textContent = fmtPct(cpu);
|
||||||
|
if (dashCpuBar) dashCpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%";
|
||||||
|
if (dashCpuText) dashCpuText.textContent = cpu >= 85 ? "Carga alta" : cpu >= 60 ? "Carga moderada" : "Carga normal";
|
||||||
|
if (dashRamVal) dashRamVal.textContent = mem == null ? "--%" : fmtPct(mem);
|
||||||
|
if (dashRamBar) dashRamBar.style.width = mem == null ? "0%" : Math.min(100, Math.max(0, mem)) + "%";
|
||||||
|
if (dashRamText) {
|
||||||
|
const used = s.mem_used_bytes, total = s.mem_total_bytes;
|
||||||
|
dashRamText.textContent = used != null && total != null ? `${fmtBytes(used)} / ${fmtBytes(total)}` : "Memória usada";
|
||||||
|
}
|
||||||
|
const ifaces = Array.isArray(s.interfaces) ? s.interfaces : [];
|
||||||
|
let rx = 0, tx = 0, rxTotal = 0, txTotal = 0;
|
||||||
|
ifaces.forEach(it => {
|
||||||
|
rx += Number(it.rx_mbps || 0);
|
||||||
|
tx += Number(it.tx_mbps || 0);
|
||||||
|
rxTotal += Number(it.rx_bytes || 0);
|
||||||
|
txTotal += Number(it.tx_bytes || 0);
|
||||||
|
});
|
||||||
|
if (dashNetVal) dashNetVal.textContent = `${fmtMbps(rx + tx)} Mb/s`;
|
||||||
|
if (dashNetText) dashNetText.textContent = `RX ${fmtMbps(rx)} · TX ${fmtMbps(tx)} Mb/s`;
|
||||||
|
if (dashNetTotal) dashNetTotal.textContent = `Total ${fmtBytes(rxTotal + txTotal)}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const res = await api("/api/stats");
|
const res = await api("/api/stats");
|
||||||
const s = await res.json();
|
const s = await res.json();
|
||||||
|
updateDashboardStats(s);
|
||||||
const cpu = s?.cpu_percent ?? 0;
|
const cpu = s?.cpu_percent ?? 0;
|
||||||
cpuVal.textContent = fmtPct(cpu);
|
cpuVal.textContent = fmtPct(cpu);
|
||||||
cpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%";
|
cpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%";
|
||||||
|
|||||||
@@ -64,8 +64,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<button class="toolbar-pill" type="button" title="Idioma">🇧🇷 PT</button>
|
|
||||||
<button class="icon-btn" type="button" title="Notificações">🔔</button>
|
|
||||||
<button class="icon-btn" id="themeToggle" type="button" title="Alternar tema">☀</button>
|
<button class="icon-btn" id="themeToggle" type="button" title="Alternar tema">☀</button>
|
||||||
<div class="user-pill">
|
<div class="user-pill">
|
||||||
<span id="roleChip"></span>
|
<span id="roleChip"></span>
|
||||||
@@ -133,6 +131,33 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="dash-icon">◇</div>
|
<div class="dash-icon">◇</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dash-card accent-blue superadmin-only hidden">
|
||||||
|
<div class="dash-card-main">
|
||||||
|
<span class="dash-label">CPU</span>
|
||||||
|
<strong id="dashCpuVal">--%</strong>
|
||||||
|
<small id="dashCpuText">Carga do processador</small>
|
||||||
|
<div class="mini-meter dashboard-meter"><span id="dashCpuBar"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="dash-icon">◴</div>
|
||||||
|
</div>
|
||||||
|
<div class="dash-card accent-green superadmin-only hidden">
|
||||||
|
<div class="dash-card-main">
|
||||||
|
<span class="dash-label">RAM</span>
|
||||||
|
<strong id="dashRamVal">--%</strong>
|
||||||
|
<small id="dashRamText">Memória usada</small>
|
||||||
|
<div class="mini-meter dashboard-meter"><span id="dashRamBar"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="dash-icon">▣</div>
|
||||||
|
</div>
|
||||||
|
<div class="dash-card accent-purple superadmin-only hidden">
|
||||||
|
<div class="dash-card-main">
|
||||||
|
<span class="dash-label">Rede</span>
|
||||||
|
<strong id="dashNetVal">--</strong>
|
||||||
|
<small id="dashNetText">RX -- · TX -- Mb/s</small>
|
||||||
|
<small id="dashNetTotal">Total --</small>
|
||||||
|
</div>
|
||||||
|
<div class="dash-icon">⇅</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid2 dashboard-lower">
|
<div class="grid2 dashboard-lower">
|
||||||
@@ -273,7 +298,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-hdr">
|
<div class="card-hdr">
|
||||||
<div class="card-title">Xray Core <span class="chip" id="xrayChip">--</span></div>
|
<div class="card-title">Xray Core <span class="chip" id="xrayChip">--</span></div>
|
||||||
<div class="xray-admin-only" style="display:flex;gap:5px;flex-wrap:wrap;">
|
<div class="card-actions xray-admin-only">
|
||||||
<button class="btn btn-ghost btn-sm" id="xStartBtn">Start</button>
|
<button class="btn btn-ghost btn-sm" id="xStartBtn">Start</button>
|
||||||
<button class="btn btn-danger btn-sm" id="xStopBtn">Stop</button>
|
<button class="btn btn-danger btn-sm" id="xStopBtn">Stop</button>
|
||||||
<button class="btn btn-ghost btn-sm" id="xRestartBtn">Restart</button>
|
<button class="btn btn-ghost btn-sm" id="xRestartBtn">Restart</button>
|
||||||
@@ -295,7 +320,7 @@
|
|||||||
<div class="card" style="margin-top:12px;">
|
<div class="card" style="margin-top:12px;">
|
||||||
<div class="card-hdr">
|
<div class="card-hdr">
|
||||||
<div class="card-title">Inbounds & Clients</div>
|
<div class="card-title">Inbounds & Clients</div>
|
||||||
<button class="btn btn-ghost btn-sm" id="xLoadInboundsBtn">Reload</button>
|
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadInboundsBtn">Reload</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="inboundsContainer">
|
<div id="inboundsContainer">
|
||||||
<div class="hint" style="padding:8px 0;">Loading inbounds…</div>
|
<div class="hint" style="padding:8px 0;">Loading inbounds…</div>
|
||||||
@@ -306,7 +331,7 @@
|
|||||||
<div class="card xray-admin-only" style="margin-top:12px;">
|
<div class="card xray-admin-only" style="margin-top:12px;">
|
||||||
<div class="card-hdr">
|
<div class="card-hdr">
|
||||||
<div class="card-title">Xray Config</div>
|
<div class="card-title">Xray Config</div>
|
||||||
<div style="display:flex;gap:4px;">
|
<div class="card-actions">
|
||||||
<button class="btn btn-sm" id="xrayWizardTabBtn" onclick="setXrayCfgMode('wizard')">Visual</button>
|
<button class="btn btn-sm" id="xrayWizardTabBtn" onclick="setXrayCfgMode('wizard')">Visual</button>
|
||||||
<button class="btn btn-ghost btn-sm" id="xrayJsonTabBtn" onclick="setXrayCfgMode('json')">JSON</button>
|
<button class="btn btn-ghost btn-sm" id="xrayJsonTabBtn" onclick="setXrayCfgMode('json')">JSON</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -480,7 +505,7 @@
|
|||||||
<div class="card xray-admin-only" style="margin-top:12px;">
|
<div class="card xray-admin-only" style="margin-top:12px;">
|
||||||
<div class="card-hdr">
|
<div class="card-hdr">
|
||||||
<div class="card-title">Logs <span class="chip">last 200 lines</span></div>
|
<div class="card-title">Logs <span class="chip">last 200 lines</span></div>
|
||||||
<button class="btn btn-ghost btn-sm" id="xLoadLogsBtn">Refresh</button>
|
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadLogsBtn">Refresh</button></div>
|
||||||
</div>
|
</div>
|
||||||
<pre class="log-box" id="xLogsBox"></pre>
|
<pre class="log-box" id="xLogsBox"></pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -834,9 +859,11 @@
|
|||||||
</div><!-- /grid2 -->
|
</div><!-- /grid2 -->
|
||||||
|
|
||||||
<!-- Save bar -->
|
<!-- Save bar -->
|
||||||
<div class="form-actions" style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border);">
|
<div class="save-bar">
|
||||||
<button class="btn" onclick="saveServerConfig()">Save Config</button>
|
<div class="card-actions save-bar-actions">
|
||||||
<button class="btn btn-ghost" onclick="loadServerConfig()">Reload</button>
|
<button class="btn" onclick="saveServerConfig()">Save Config</button>
|
||||||
|
<button class="btn btn-ghost" onclick="loadServerConfig()">Reload</button>
|
||||||
|
</div>
|
||||||
<span id="srvCfgStatus" class="hint">All service changes apply live.</span>
|
<span id="srvCfgStatus" class="hint">All service changes apply live.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -409,7 +409,11 @@ func (m *XrayManager) refreshRuntimeStats() {
|
|||||||
for email, counters := range traffic {
|
for email, counters := range traffic {
|
||||||
prev := m.statsByEmail[email]
|
prev := m.statsByEmail[email]
|
||||||
st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive}
|
st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive}
|
||||||
if prev.Email != "" && (counters.Uplink != prev.Uplink || counters.Downlink != prev.Downlink) {
|
changed := counters.Uplink != prev.Uplink || counters.Downlink != prev.Downlink
|
||||||
|
// When a panel starts after Xray, the first successful poll may already
|
||||||
|
// contain non-zero per-user traffic. Treat that as recent activity so
|
||||||
|
// online counters do not stay at zero until the next byte moves.
|
||||||
|
if changed && (prev.Email != "" || counters.Uplink+counters.Downlink > 0) {
|
||||||
st.LastActive = now
|
st.LastActive = now
|
||||||
}
|
}
|
||||||
m.statsByEmail[email] = st
|
m.statsByEmail[email] = st
|
||||||
@@ -441,36 +445,65 @@ func (m *XrayManager) queryUserTraffic() (map[string]xrayTrafficCounters, error)
|
|||||||
|
|
||||||
func parseXrayStatsOutput(out []byte) map[string]xrayTrafficCounters {
|
func parseXrayStatsOutput(out []byte) map[string]xrayTrafficCounters {
|
||||||
result := map[string]xrayTrafficCounters{}
|
result := map[string]xrayTrafficCounters{}
|
||||||
|
type rawStat struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
}
|
||||||
var js struct {
|
var js struct {
|
||||||
Stat []struct {
|
Stat []rawStat `json:"stat"`
|
||||||
Name string `json:"name"`
|
Stats []rawStat `json:"stats"`
|
||||||
Value int64 `json:"value"`
|
|
||||||
} `json:"stat"`
|
|
||||||
Stats []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Value int64 `json:"value"`
|
|
||||||
} `json:"stats"`
|
|
||||||
}
|
}
|
||||||
if json.Unmarshal(out, &js) == nil {
|
if json.Unmarshal(out, &js) == nil {
|
||||||
for _, st := range js.Stat {
|
for _, st := range js.Stat {
|
||||||
addXrayCounter(result, st.Name, st.Value)
|
addXrayCounter(result, st.Name, parseXrayStatValue(st.Value))
|
||||||
}
|
}
|
||||||
for _, st := range js.Stats {
|
for _, st := range js.Stats {
|
||||||
addXrayCounter(result, st.Name, st.Value)
|
addXrayCounter(result, st.Name, parseXrayStatValue(st.Value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(result) > 0 {
|
if len(result) > 0 {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
re := regexp.MustCompile(`(?s)name:\s*"([^"]+)"\s+value:\s*([0-9]+)`)
|
// Text/protobuf form: name: "user>>>..." value: 123
|
||||||
for _, m := range re.FindAllSubmatch(out, -1) {
|
reText := regexp.MustCompile(`(?s)name:\s*"([^"]+)"\s+value:\s*([0-9]+)`)
|
||||||
|
for _, m := range reText.FindAllSubmatch(out, -1) {
|
||||||
|
v, _ := strconv.ParseInt(string(m[2]), 10, 64)
|
||||||
|
addXrayCounter(result, string(m[1]), v)
|
||||||
|
}
|
||||||
|
if len(result) > 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some Xray/protobuf JSON builds encode int64 values as strings.
|
||||||
|
reJSON := regexp.MustCompile(`"name"\s*:\s*"([^"]+)"[^}]*"value"\s*:\s*"?([0-9]+)"?`)
|
||||||
|
for _, m := range reJSON.FindAllSubmatch(out, -1) {
|
||||||
v, _ := strconv.ParseInt(string(m[2]), 10, 64)
|
v, _ := strconv.ParseInt(string(m[2]), 10, 64)
|
||||||
addXrayCounter(result, string(m[1]), v)
|
addXrayCounter(result, string(m[1]), v)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseXrayStatValue(raw json.RawMessage) int64 {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
if err := json.Unmarshal(raw, &n); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
var f float64
|
||||||
|
if err := json.Unmarshal(raw, &f); err == nil {
|
||||||
|
return int64(f)
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw, &s); err == nil {
|
||||||
|
v, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func addXrayCounter(result map[string]xrayTrafficCounters, name string, value int64) {
|
func addXrayCounter(result map[string]xrayTrafficCounters, name string, value int64) {
|
||||||
parts := strings.Split(name, ">>>")
|
parts := strings.Split(name, ">>>")
|
||||||
if len(parts) < 5 || parts[0] != "user" || parts[2] != "traffic" {
|
if len(parts) < 5 || parts[0] != "user" || parts[2] != "traffic" {
|
||||||
@@ -756,6 +789,10 @@ func ensureXrayStatsAPIConfig(raw map[string]interface{}) (bool, xrayStatsConfig
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ensureXrayClientEmails(raw) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
return changed, checkXrayStatsAPIConfig(raw)
|
return changed, checkXrayStatsAPIConfig(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,6 +839,9 @@ func checkXrayStatsAPIConfig(raw map[string]interface{}) xrayStatsConfigCheck {
|
|||||||
if findObjectByTag(outbounds, "api") == nil {
|
if findObjectByTag(outbounds, "api") == nil {
|
||||||
missing = append(missing, "api outbound")
|
missing = append(missing, "api outbound")
|
||||||
}
|
}
|
||||||
|
if n := countXrayClientEmailIssues(raw); n > 0 {
|
||||||
|
missing = append(missing, fmt.Sprintf("%d client stats labels", n))
|
||||||
|
}
|
||||||
routing := asObject(raw["routing"])
|
routing := asObject(raw["routing"])
|
||||||
if routing == nil {
|
if routing == nil {
|
||||||
missing = append(missing, "routing api rule")
|
missing = append(missing, "routing api rule")
|
||||||
@@ -845,6 +885,129 @@ func discoverAPIServerFromRaw(raw map[string]interface{}) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func countXrayClientEmailIssues(raw map[string]interface{}) int {
|
||||||
|
inbounds, _ := raw["inbounds"].([]interface{})
|
||||||
|
seen := map[string]int{}
|
||||||
|
issues := 0
|
||||||
|
for _, ib := range inbounds {
|
||||||
|
ibMap, ok := ib.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
proto, _ := ibMap["protocol"].(string)
|
||||||
|
if !xrayClientProtos[strings.ToLower(proto)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings, _ := ibMap["settings"].(map[string]interface{})
|
||||||
|
if settings == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clients, _ := settings["clients"].([]interface{})
|
||||||
|
for _, item := range clients {
|
||||||
|
cm, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
email := safeXrayStatsEmail(firstNonEmptyString(cm["email"]))
|
||||||
|
if email == "" || seen[email] > 0 {
|
||||||
|
issues++
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
seen[email]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureXrayClientEmails(raw map[string]interface{}) bool {
|
||||||
|
changed := false
|
||||||
|
inbounds, _ := raw["inbounds"].([]interface{})
|
||||||
|
seen := map[string]int{}
|
||||||
|
for _, ib := range inbounds {
|
||||||
|
ibMap, ok := ib.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
proto, _ := ibMap["protocol"].(string)
|
||||||
|
if !xrayClientProtos[strings.ToLower(proto)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
settings, _ := ibMap["settings"].(map[string]interface{})
|
||||||
|
if settings == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clients, _ := settings["clients"].([]interface{})
|
||||||
|
for idx, item := range clients {
|
||||||
|
cm, ok := item.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
email := safeXrayStatsEmail(firstNonEmptyString(cm["email"]))
|
||||||
|
if email == "" || seen[email] > 0 {
|
||||||
|
base := firstNonEmptyString(cm["name"], cm["email"], cm["id"], cm["password"])
|
||||||
|
if base == "" {
|
||||||
|
base = fmt.Sprintf("client-%d", idx+1)
|
||||||
|
}
|
||||||
|
email = uniqueXrayEmail(base, seen)
|
||||||
|
cm["email"] = email
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
seen[email]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyString(values ...interface{}) string {
|
||||||
|
for _, v := range values {
|
||||||
|
s := strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
if s != "" && s != "<nil>" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueXrayEmail(base string, seen map[string]int) string {
|
||||||
|
email := safeXrayStatsEmail(base)
|
||||||
|
if email == "" {
|
||||||
|
email = "xray-client"
|
||||||
|
}
|
||||||
|
if len(email) > 40 {
|
||||||
|
email = email[:40]
|
||||||
|
email = strings.Trim(email, "-_.")
|
||||||
|
if email == "" {
|
||||||
|
email = "xray-client"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidate := email
|
||||||
|
for i := 2; seen[candidate] > 0; i++ {
|
||||||
|
candidate = fmt.Sprintf("%s-%d", email, i)
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeXrayStatsEmail(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" || s == "<nil>" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
lastDash := false
|
||||||
|
for _, r := range s {
|
||||||
|
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '@' || r == '.' || r == '_' || r == '-'
|
||||||
|
if ok {
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastDash = false
|
||||||
|
} else if !lastDash {
|
||||||
|
b.WriteByte('-')
|
||||||
|
lastDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), "-_.")
|
||||||
|
}
|
||||||
|
|
||||||
func asObject(v interface{}) map[string]interface{} {
|
func asObject(v interface{}) map[string]interface{} {
|
||||||
m, _ := v.(map[string]interface{})
|
m, _ := v.(map[string]interface{})
|
||||||
return m
|
return m
|
||||||
@@ -1033,9 +1196,10 @@ func handleXrayLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// XrayClientInfo is a single client entry inside an Xray inbound.
|
// XrayClientInfo is a single client entry inside an Xray inbound.
|
||||||
type XrayClientInfo struct {
|
type XrayClientInfo struct {
|
||||||
UUID string `json:"id"`
|
UUID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Password string `json:"password,omitempty"`
|
||||||
Level int `json:"level,omitempty"`
|
Email string `json:"email"`
|
||||||
|
Level int `json:"level,omitempty"`
|
||||||
// Runtime counters from the Xray stats API. Online means this user's
|
// Runtime counters from the Xray stats API. Online means this user's
|
||||||
// traffic counters changed inside the configured online window.
|
// traffic counters changed inside the configured online window.
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
@@ -1104,6 +1268,11 @@ func (m *XrayManager) ListInbounds() ([]XrayInboundInfo, error) {
|
|||||||
if clients == nil {
|
if clients == nil {
|
||||||
clients = []XrayClientInfo{}
|
clients = []XrayClientInfo{}
|
||||||
}
|
}
|
||||||
|
for i := range clients {
|
||||||
|
if clients[i].UUID == "" && clients[i].Password != "" {
|
||||||
|
clients[i].UUID = clients[i].Password
|
||||||
|
}
|
||||||
|
}
|
||||||
result = append(result, XrayInboundInfo{
|
result = append(result, XrayInboundInfo{
|
||||||
Tag: ib.Tag,
|
Tag: ib.Tag,
|
||||||
Protocol: strings.ToLower(ib.Protocol),
|
Protocol: strings.ToLower(ib.Protocol),
|
||||||
|
|||||||
Reference in New Issue
Block a user