diff --git a/admin/assets/app.css b/admin/assets/app.css index 18ba131..5d007e8 100644 --- a/admin/assets/app.css +++ b/admin/assets/app.css @@ -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}} .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;} + +/* 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;} +} diff --git a/admin/assets/app.js b/admin/assets/app.js index 61b9836..a7b70c8 100644 --- a/admin/assets/app.js +++ b/admin/assets/app.js @@ -37,6 +37,15 @@ const dashServers = document.getElementById("dashServers"); const dashServerStatus = document.getElementById("dashServerStatus"); const dashXrayClients = document.getElementById("dashXrayClients"); 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 dashQuotaBar = document.getElementById("dashQuotaBar"); const dashQuotaText = document.getElementById("dashQuotaText"); @@ -85,6 +94,7 @@ const xRunning = document.getElementById("xRunning"); const xPID = document.getElementById("xPID"); const xUptime = document.getElementById("xUptime"); const xStatus = document.getElementById("xStatus"); +const xOnlineUsers = document.getElementById("xOnlineUsers"); const xCfgEditor = document.getElementById("xCfgEditor"); const xCfgStatus = document.getElementById("xCfgStatus"); const xLogsBox = document.getElementById("xLogsBox"); @@ -468,6 +478,7 @@ function refreshDashboard() { loadUsersSilent(); loadInbounds(); loadXrayStatus(); + if (currentRole === "superadmin") loadStats(); if (currentRole === "reseller") loadMe(); } @@ -634,7 +645,7 @@ document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("s 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("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds(); }); document.getElementById("xLoadInboundsBtn").addEventListener("click", loadInbounds); document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg); 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) { updateDashboardXray(inbounds); if (!inbounds.length) { @@ -815,7 +844,7 @@ function renderInbounds(inbounds) { const copyBtn = document.createElement("button"); copyBtn.className = "btn btn-ghost btn-sm"; 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"); editBtn.className = "btn btn-warn btn-sm"; editBtn.style.marginLeft = "4px"; @@ -1043,10 +1072,37 @@ async function deleteReseller(username) { // ─── Stats ──────────────────────────────────────────────────────────────────── 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() { try { const res = await api("/api/stats"); const s = await res.json(); + updateDashboardStats(s); const cpu = s?.cpu_percent ?? 0; cpuVal.textContent = fmtPct(cpu); cpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%"; diff --git a/admin/index.html b/admin/index.html index efb9596..11ab2cb 100644 --- a/admin/index.html +++ b/admin/index.html @@ -64,8 +64,6 @@
- -
@@ -133,6 +131,33 @@
+ + +
@@ -273,7 +298,7 @@
Xray Core --
-
+
@@ -295,7 +320,7 @@
Inbounds & Clients
- +
Loading inbounds…
@@ -306,7 +331,7 @@
Xray Config
-
+
@@ -480,7 +505,7 @@
Logs last 200 lines
- +

     
@@ -834,9 +859,11 @@
-
- - +
+
+ + +
All service changes apply live.
diff --git a/xray_integration.go b/xray_integration.go index af38267..7852c70 100644 --- a/xray_integration.go +++ b/xray_integration.go @@ -409,7 +409,11 @@ func (m *XrayManager) refreshRuntimeStats() { 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) { + 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 } m.statsByEmail[email] = st @@ -441,36 +445,65 @@ func (m *XrayManager) queryUserTraffic() (map[string]xrayTrafficCounters, error) func parseXrayStatsOutput(out []byte) map[string]xrayTrafficCounters { result := map[string]xrayTrafficCounters{} + type rawStat struct { + Name string `json:"name"` + Value json.RawMessage `json:"value"` + } 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"` + Stat []rawStat `json:"stat"` + Stats []rawStat `json:"stats"` } if json.Unmarshal(out, &js) == nil { for _, st := range js.Stat { - addXrayCounter(result, st.Name, st.Value) + addXrayCounter(result, st.Name, parseXrayStatValue(st.Value)) } for _, st := range js.Stats { - addXrayCounter(result, st.Name, st.Value) + addXrayCounter(result, st.Name, parseXrayStatValue(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) { + // Text/protobuf form: name: "user>>>..." value: 123 + 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) addXrayCounter(result, string(m[1]), v) } 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) { parts := strings.Split(name, ">>>") if len(parts) < 5 || parts[0] != "user" || parts[2] != "traffic" { @@ -756,6 +789,10 @@ func ensureXrayStatsAPIConfig(raw map[string]interface{}) (bool, xrayStatsConfig changed = true } + if ensureXrayClientEmails(raw) { + changed = true + } + return changed, checkXrayStatsAPIConfig(raw) } @@ -802,6 +839,9 @@ func checkXrayStatsAPIConfig(raw map[string]interface{}) xrayStatsConfigCheck { if findObjectByTag(outbounds, "api") == nil { 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"]) if routing == nil { missing = append(missing, "routing api rule") @@ -845,6 +885,129 @@ func discoverAPIServerFromRaw(raw map[string]interface{}) string { 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 != "" { + 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 == "" { + 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{} { m, _ := v.(map[string]interface{}) return m @@ -1033,9 +1196,10 @@ func handleXrayLogs(w http.ResponseWriter, r *http.Request) { // XrayClientInfo is a single client entry inside an Xray inbound. type XrayClientInfo struct { - UUID string `json:"id"` - Email string `json:"email"` - Level int `json:"level,omitempty"` + UUID string `json:"id"` + Password string `json:"password,omitempty"` + 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"` @@ -1104,6 +1268,11 @@ func (m *XrayManager) ListInbounds() ([]XrayInboundInfo, error) { if clients == nil { clients = []XrayClientInfo{} } + for i := range clients { + if clients[i].UUID == "" && clients[i].Password != "" { + clients[i].UUID = clients[i].Password + } + } result = append(result, XrayInboundInfo{ Tag: ib.Tag, Protocol: strings.ToLower(ib.Protocol),