From e00a7bd93c47560f324b8e362bfc6ea553247fc5 Mon Sep 17 00:00:00 2001 From: penguinehis Date: Sun, 10 May 2026 18:14:16 -0300 Subject: [PATCH] Fix panel --- admin/assets/app.js | 57 ++++++++++++++++++++++-------- admin/index.html | 4 +-- main.go | 47 +++++++++++++++++++++++-- xray_integration.go | 85 +++++++++++++++++++++++++++++++++++---------- 4 files changed, 155 insertions(+), 38 deletions(-) diff --git a/admin/assets/app.js b/admin/assets/app.js index a7b70c8..86c0bcd 100644 --- a/admin/assets/app.js +++ b/admin/assets/app.js @@ -9,6 +9,7 @@ let tlsForwardersState = []; let editingXrayClientId = null; let wzInbounds = []; let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null }; +let currentTab = "dashboard"; // ─── DOM refs ───────────────────────────────────────────────────────────────── const loginOverlay = document.getElementById("loginOverlay"); @@ -206,6 +207,7 @@ const tabTitles = { }; function selectTab(tab) { + currentTab = tab; const pane = document.getElementById("tab-" + tab); const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`); if (!pane || !btn) return; @@ -311,14 +313,18 @@ function initAfterLogin() { selectTab("dashboard"); if (currentRole === "superadmin") { - loadStats(); - statsTimer = setInterval(loadStats, 2000); + loadDashboardStats(); + statsTimer = setInterval(() => { + loadDashboardStats(); + if (currentTab === "stats") loadStats(); + }, 2000); } else { loadMe(); } - xrayTimer = setInterval(loadXrayStatus, 7000); + xrayTimer = setInterval(() => { loadXrayStatus(); if (currentTab === "xray") loadInbounds(); }, 7000); loadUsers(); + loadXrayStatus(); loadInbounds(); usersTimer = setInterval(() => loadUsersSilent(), 3000); } @@ -1072,6 +1078,22 @@ async function deleteReseller(username) { // ─── Stats ──────────────────────────────────────────────────────────────────── document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats); +async function loadDashboardStats() { + try { + const res = await api("/api/stats"); + if (!res.ok) throw new Error(await res.text()); + const s = await res.json(); + updateDashboardStats(s); + } catch (e) { + if (e.message === "auth") doAuthError(); + else { + if (dashCpuVal) dashCpuVal.textContent = "erro"; + if (dashRamVal) dashRamVal.textContent = "erro"; + if (dashNetVal) dashNetVal.textContent = "erro"; + } + } +} + function updateDashboardStats(s) { if (!s) return; const cpu = Number(s.cpu_percent ?? 0); @@ -1101,28 +1123,33 @@ function updateDashboardStats(s) { async function loadStats() { try { const res = await api("/api/stats"); + if (!res.ok) throw new Error(await res.text()); 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)) + "%"; - const mp = s?.mem_percent ?? null; - memVal.textContent = mp == null ? "--%" : fmtPct(mp); - memBar.style.width = mp == null ? "0%" : Math.min(100, Math.max(0, mp)) + "%"; + const cpu = Number(s?.cpu_percent ?? 0); + if (cpuVal) cpuVal.textContent = fmtPct(cpu); + if (cpuBar) cpuBar.style.width = Math.min(100, Math.max(0, cpu)) + "%"; + const mp = s?.mem_percent == null ? null : Number(s.mem_percent); + if (memVal) memVal.textContent = mp == null ? "--%" : fmtPct(mp); + if (memBar) memBar.style.width = mp == null ? "0%" : Math.min(100, Math.max(0, mp)) + "%"; const mu = s?.mem_used_bytes, mt = s?.mem_total_bytes; - memDetail.textContent = (mu != null && mt != null) ? `${fmtBytes(mu)} / ${fmtBytes(mt)}` : ""; + if (memDetail) memDetail.textContent = (mu != null && mt != null) ? `${fmtBytes(mu)} / ${fmtBytes(mt)}` : ""; const ifaces = Array.isArray(s.interfaces) ? s.interfaces : []; - ifaceBody.innerHTML = ""; + if (ifaceBody) ifaceBody.innerHTML = ""; let totRx = 0, totTx = 0; ifaces.forEach(it => { - totRx += it.rx_bytes||0; totTx += it.tx_bytes||0; + totRx += Number(it.rx_bytes||0); totTx += Number(it.tx_bytes||0); + if (!ifaceBody) return; const tr = document.createElement("tr"); tr.innerHTML = `${it.name}${fmtMbps(it.rx_mbps)}${fmtMbps(it.tx_mbps)}${fmtBytes(it.rx_bytes)}${fmtBytes(it.tx_bytes)}`; ifaceBody.appendChild(tr); }); - ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`; - statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString(); - } catch (e) { if (e.message==="auth") doAuthError(); } + if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`; + if (statsUpdated) statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString(); + } catch (e) { + if (e.message==="auth") doAuthError(); + else if (statsUpdated) statsUpdated.textContent = "Erro ao carregar stats."; + } } resetIfaceStatsBtn?.addEventListener("click", resetInterfaceStats); diff --git a/admin/index.html b/admin/index.html index 11ab2cb..7cd98f5 100644 --- a/admin/index.html +++ b/admin/index.html @@ -4,7 +4,7 @@ DragonCore Panel - +
@@ -876,6 +876,6 @@
- + diff --git a/main.go b/main.go index 6bce33e..b85d2fd 100644 --- a/main.go +++ b/main.go @@ -561,6 +561,48 @@ func setCurrentStats(s StatsDTO) { statsMu.Unlock() } +// primeCurrentStats fills RAM and interface totals immediately at startup so +// the dashboard does not show placeholder values while waiting for the first +// polling interval. CPU still becomes accurate after the second /proc/stat +// sample, but it is rendered as 0.0% instead of --. +func primeCurrentStats() { + netMap, _ := readNetDev() + interfaces := make([]InterfaceStats, 0, len(netMap)) + for name, ctrs := range netMap { + if isIgnoredInterface(name) { + continue + } + st := InterfaceStats{Name: name} + if ifaceTotalsMgr != nil { + rxTotal, txTotal := ifaceTotalsMgr.ApplyKernel(name, ctrs.RxBytes, ctrs.TxBytes) + st.RxBytes = rxTotal + st.TxBytes = txTotal + } else { + st.RxBytes = ctrs.RxBytes + st.TxBytes = ctrs.TxBytes + } + interfaces = append(interfaces, st) + } + sort.Slice(interfaces, func(i, j int) bool { return interfaces[i].Name < interfaces[j].Name }) + memTotal, memAvail, _ := readMemInfo() + var memUsed uint64 + var memPercent float64 + if memTotal > 0 { + if memAvail <= memTotal { + memUsed = memTotal - memAvail + memPercent = 100.0 * float64(memUsed) / float64(memTotal) + } + } + setCurrentStats(StatsDTO{ + CPUPercent: 0, + MemTotal: memTotal, + MemUsed: memUsed, + MemAvail: memAvail, + MemPercent: memPercent, + Interfaces: interfaces, + }) +} + type IfaceTotals struct { Iface string TotalRxBytes uint64 @@ -1310,8 +1352,8 @@ func startAdminAPI(store *Store, addr string, adminDir string) { mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store)))) mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store)))) - // Superadmin-only: server stats + DNSTT - mux.Handle("/api/stats", saSession(http.HandlerFunc(handleStats))) + // Server stats: visible to authenticated sessions; reset remains superadmin-only. + mux.Handle("/api/stats", sessionMiddleware(http.HandlerFunc(handleStats))) mux.Handle("/api/stats/interfaces/reset", saSession(http.HandlerFunc(handleResetInterfaceStats(store)))) mux.Handle("/api/vnstat", saSession(http.HandlerFunc(handleVnstat(store)))) mux.Handle("/api/vnstat/reset", saSession(http.HandlerFunc(handleVnstatReset(store)))) @@ -2586,6 +2628,7 @@ func main() { } // start background collector for CPU + interface stats + primeCurrentStats() startStatsCollector() adminAddr := os.Getenv("ADMIN_HTTP_ADDR") diff --git a/xray_integration.go b/xray_integration.go index 7852c70..3a2c472 100644 --- a/xray_integration.go +++ b/xray_integration.go @@ -251,6 +251,7 @@ type XrayStatusDTO struct { // Status returns a snapshot of the current xray process state. func (m *XrayManager) Status() XrayStatusDTO { + m.refreshRuntimeStatsIfStale(3 * time.Second) m.mu.Lock() s := XrayStatusDTO{} if m.cfg != nil { @@ -406,18 +407,35 @@ func (m *XrayManager) refreshRuntimeStats() { if m.statsByEmail == nil { m.statsByEmail = make(map[string]xrayRuntimeStat, len(traffic)) } + seen := make(map[string]bool, len(traffic)) for email, counters := range traffic { + seen[email] = true prev := m.statsByEmail[email] st := xrayRuntimeStat{Email: email, Uplink: counters.Uplink, Downlink: counters.Downlink, LastActive: prev.LastActive} 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. + // First successful poll with non-zero traffic means the client has been + // active since Xray started. Later polls refresh LastActive only when bytes + // move, which avoids counting old idle clients forever. if changed && (prev.Email != "" || counters.Uplink+counters.Downlink > 0) { st.LastActive = now } m.statsByEmail[email] = st } + // Keep old entries, but do not delete them immediately. Xray may omit zero + // counters for users that have not moved traffic yet. + _ = seen +} + +func (m *XrayManager) refreshRuntimeStatsIfStale(maxAge time.Duration) { + if !m.isRunningSnapshot() { + return + } + m.statsMu.RLock() + last := m.lastStatsPoll + m.statsMu.RUnlock() + if last.IsZero() || time.Since(last) >= maxAge { + m.refreshRuntimeStats() + } } func (m *XrayManager) queryUserTraffic() (map[string]xrayTrafficCounters, error) { @@ -425,16 +443,18 @@ func (m *XrayManager) queryUserTraffic() (map[string]xrayTrafficCounters, error) if !ok { return nil, fmt.Errorf("Xray API stats not configured; add an api inbound or set xray.api_server") } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() attempts := [][]string{ {"api", "statsquery", "--server=" + apiServer, "-pattern", "user>>>", "-reset=false"}, + {"api", "statsquery", "--server", apiServer, "-pattern", "user>>>", "-reset=false"}, + {"api", "statsquery", "--server=" + apiServer, "-pattern", "user>>>"}, {"api", "statsquery", "--server", apiServer, "-pattern", "user>>>"}, } var lastErr error for _, args := range attempts { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) cmd := exec.CommandContext(ctx, binPath, args...) out, err := cmd.CombinedOutput() + cancel() if err == nil { return parseXrayStatsOutput(out), nil } @@ -505,12 +525,17 @@ func parseXrayStatValue(raw json.RawMessage) int64 { } func addXrayCounter(result map[string]xrayTrafficCounters, name string, value int64) { + // Xray user traffic stats are normally named: + // user>>>EMAIL>>>traffic>>>uplink + // user>>>EMAIL>>>traffic>>>downlink + // Older code expected a fifth segment and therefore ignored valid Xray + // Stats API responses, which made every client look offline. parts := strings.Split(name, ">>>") - if len(parts) < 5 || parts[0] != "user" || parts[2] != "traffic" { + if len(parts) < 4 || parts[0] != "user" || parts[2] != "traffic" { return } - email := parts[1] - direction := parts[4] + email := strings.TrimSpace(parts[1]) + direction := strings.ToLower(strings.TrimSpace(parts[3])) if email == "" { return } @@ -527,17 +552,22 @@ func addXrayCounter(result map[string]xrayTrafficCounters, name string, value in } func (m *XrayManager) RuntimeStatsForEmail(email string) (xrayRuntimeStat, bool) { - email = strings.TrimSpace(email) - if email == "" { - return xrayRuntimeStat{}, false - } + return m.RuntimeStatsForKeys(email) +} + +func (m *XrayManager) RuntimeStatsForKeys(keys ...string) (xrayRuntimeStat, bool) { m.statsMu.RLock() - st, ok := m.statsByEmail[email] - m.statsMu.RUnlock() - if !ok || st.Email == "" { - return xrayRuntimeStat{}, false + defer m.statsMu.RUnlock() + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if st, ok := m.statsByEmail[key]; ok && st.Email != "" { + return st, true + } } - return st, true + return xrayRuntimeStat{}, false } func (m *XrayManager) CountOnlineUsers() int { @@ -953,6 +983,10 @@ func ensureXrayClientEmails(raw map[string]interface{}) bool { cm["email"] = email changed = true } + if _, ok := cm["level"]; !ok { + cm["level"] = float64(0) + changed = true + } seen[email]++ } } @@ -1066,6 +1100,18 @@ func handleXrayStatus(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusMethodNotAllowed) return } + // Be practical for old/manual configs: if the panel detects missing Stats API + // pieces or missing per-client stat labels, repair once automatically. This + // avoids a dashboard that says "OK" but continues to show zero Xray online + // users until a technical admin manually edits JSON. + if check, err := xrayMgr.CheckStatsAPIConfig(); err == nil && !check.Configured { + wasRunning := xrayMgr.isRunningSnapshot() + if changed, err := xrayMgr.EnsureStatsAPIConfig(); err == nil && changed && wasRunning { + if err := xrayMgr.Restart(); err != nil { + log.Printf("xray: auto stats repair restart failed: %v", err) + } + } + } status := xrayMgr.Status() if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && statsStore != nil { metas, err := statsStore.ListXrayClientsByOwner(r.Context(), sess.Username) @@ -1074,7 +1120,7 @@ func handleXrayStatus(w http.ResponseWriter, r *http.Request) { window := xrayMgr.onlineWindow() now := time.Now() for _, m := range metas { - st, ok := xrayMgr.RuntimeStatsForEmail(m.Email) + st, ok := xrayMgr.RuntimeStatsForKeys(m.Email, m.UUID, m.Name) if ok && !st.LastActive.IsZero() && now.Sub(st.LastActive) <= window { status.OnlineUsers++ } @@ -1453,6 +1499,7 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) { c.ExpiresAt = m.ExpiresAt c.MaxConns = m.MaxConns c.OwnerUsername = m.OwnerUsername + applyXrayRuntimeStats(&c) if m.ExpiresAt == nil { c.ExpirationDays = -1 } else if m.ExpiresAt.Before(now) { @@ -1485,7 +1532,7 @@ func applyXrayRuntimeStats(c *XrayClientInfo) { if c == nil { return } - st, ok := xrayMgr.RuntimeStatsForEmail(c.Email) + st, ok := xrayMgr.RuntimeStatsForKeys(c.Email, c.UUID, c.Name) if !ok { return }