diff --git a/README.md b/README.md index 3a48ed9..155ab6d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine - Endpoint XHTTP compartilhado no modo nativo: VLESS **ou** VMess em `/` e SSH em `/ssh`, usando o mesmo domínio/porta/TLS - Área compacta de infraestrutura com Servidores, Status, Monitoramento e Tráfego no mesmo seletor visual - Cartões de status ao vivo nos espaços SSH, Xray e Infraestrutura, com confirmações integradas ao painel -- Lista de usuários Xray com botões de ordenação e filtros por status, conexão, uso, validade e cota, além de cabeçalhos clicáveis como na lista SSH +- Listas de usuários SSH e Xray com botões de ordenação e filtros por status, conexão, uso, validade e cota, além de cabeçalhos clicáveis - Navegação interna consistente com o Bot: SSH/SlowDNS e Revendedores separam consulta de cadastro; Xray separa Usuários, Criar usuário, Configuração e Logs; Configurações separa Rede/SSH, SlowDNS, UDP, TLS e Xray - Contas de revendedor (reseller) com cota de usuários e escopo próprio - Gerenciamento multi-servidor (master/slave) direto pelo painel diff --git a/admin/assets/app.css b/admin/assets/app.css index 9cd2d86..3d1c65c 100644 --- a/admin/assets/app.css +++ b/admin/assets/app.css @@ -758,7 +758,7 @@ th[data-sort-key]::after{content:"";display:inline-block;width:.9em;font-size:.7 th[data-sort-key].sort-asc::after{content:" \25B2";} th[data-sort-key].sort-desc::after{content:" \25BC";} -/* Xray user list sorting and filtering */ -.xray-list-controls{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;margin:0 0 16px;padding:12px;border:1px solid rgba(139,92,246,.18);border-radius:16px;background:rgba(139,92,246,.055)} -.xray-list-control-group{display:flex;flex-direction:column;gap:6px;min-width:0}.xray-list-control-label{color:var(--muted);font-size:.63rem;font-weight:900;letter-spacing:.09em;text-transform:uppercase}.xray-list-buttons{display:flex;align-items:center;gap:5px;flex-wrap:wrap}.xray-list-filter-btn{min-height:30px;padding:5px 9px;border:1px solid rgba(148,163,184,.16);border-radius:10px;background:rgba(255,255,255,.025);color:var(--muted);font-size:.68rem;font-weight:850;cursor:pointer;transition:.15s ease}.xray-list-filter-btn:hover{color:var(--text);border-color:rgba(139,92,246,.38);background:rgba(139,92,246,.09)}.xray-list-filter-btn.active{color:#fff;border-color:rgba(139,92,246,.44);background:linear-gradient(135deg,rgba(139,92,246,.3),rgba(34,211,238,.1));box-shadow:inset 0 1px 0 rgba(255,255,255,.06)}.xray-list-filter-btn[data-direction]::after{margin-left:4px;font-size:.7em}.xray-list-filter-btn[data-direction="asc"]::after{content:"\25B2"}.xray-list-filter-btn[data-direction="desc"]::after{content:"\25BC"}.xray-list-count{margin-left:auto;white-space:nowrap} -@media(max-width:760px){.xray-list-controls{align-items:stretch}.xray-list-control-group{width:100%}.xray-list-buttons{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}.xray-list-filter-btn{width:100%}.xray-list-count{margin-left:0;align-self:flex-start}} +/* Shared SSH/Xray user list sorting and filtering */ +.user-list-controls{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;margin:0 0 16px;padding:12px;border:1px solid rgba(var(--section-accent,139,92,246),.18);border-radius:16px;background:rgba(var(--section-accent,139,92,246),.055)} +.user-list-control-group{display:flex;flex-direction:column;gap:6px;min-width:0}.user-list-control-label{color:var(--muted);font-size:.63rem;font-weight:900;letter-spacing:.09em;text-transform:uppercase}.user-list-buttons{display:flex;align-items:center;gap:5px;flex-wrap:wrap}.user-list-filter-btn{min-height:30px;padding:5px 9px;border:1px solid rgba(148,163,184,.16);border-radius:10px;background:rgba(255,255,255,.025);color:var(--muted);font-size:.68rem;font-weight:850;cursor:pointer;transition:.15s ease}.user-list-filter-btn:hover{color:var(--text);border-color:rgba(var(--section-accent,139,92,246),.38);background:rgba(var(--section-accent,139,92,246),.09)}.user-list-filter-btn.active{color:#fff;border-color:rgba(var(--section-accent,139,92,246),.44);background:linear-gradient(135deg,rgba(var(--section-accent,139,92,246),.3),rgba(34,211,238,.1));box-shadow:inset 0 1px 0 rgba(255,255,255,.06)}.user-list-filter-btn[data-direction]::after{margin-left:4px;font-size:.7em}.user-list-filter-btn[data-direction="asc"]::after{content:"\25B2"}.user-list-filter-btn[data-direction="desc"]::after{content:"\25BC"}.user-list-count{margin-left:auto;white-space:nowrap} +@media(max-width:760px){.user-list-controls{align-items:stretch}.user-list-control-group{width:100%}.user-list-buttons{display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}.user-list-filter-btn{width:100%}.user-list-count{margin-left:0;align-self:flex-start}} diff --git a/admin/assets/js/01-core.js b/admin/assets/js/01-core.js index f70c6bb..084c03a 100644 --- a/admin/assets/js/01-core.js +++ b/admin/assets/js/01-core.js @@ -277,6 +277,7 @@ function applyLanguage(lang, options = {}) { if (languageSelect) languageSelect.value = currentLang; updatePageHeading(); translateStatic(document.body); + if (typeof window.renderSSHListControls === "function") window.renderSSHListControls(); if (typeof window.renderXrayListControls === "function") window.renderXrayListControls(); document.documentElement.classList.remove("i18n-pending"); } @@ -756,7 +757,7 @@ function patchRenderedInbounds(inbounds) { // Native XHTTP/VPN tuning labels introduced by the high-traffic backpressure // update. Keep this block close to the UI code so both languages stay complete. Object.assign(I18N_TEXT["en-US"], { - "Sort by":"Sort by","Show":"Show","All":"All","Offline":"Offline","Connections":"Connections","Usage":"Usage","Quota reached":"Quota reached","{visible} of {total} users":"{visible} of {total} users","No Xray users match this filter.":"No Xray users match this filter.", + "Sort by":"Sort by","Show":"Show","All":"All","Offline":"Offline","Connections":"Connections","Usage":"Usage","Quota reached":"Quota reached","{visible} of {total} users":"{visible} of {total} users","No Xray users match this filter.":"No Xray users match this filter.","No SSH users match this filter.":"No SSH users match this filter.", "Native Xray scale tuning":"Native Xray scale tuning", "Go CPU threads (GOMAXPROCS)":"Go CPU threads (GOMAXPROCS)", "Global mux backend sessions":"Global mux backend sessions", @@ -771,7 +772,7 @@ Object.assign(I18N_TEXT["en-US"], { "XHTTP is handled as VPN tunnel traffic: packet requests use bounded backpressure and are never rejected by an HTTP request-rate ceiling. At the transport ceiling, new sockets wait in the kernel backlog instead of being reset. Keep the safe defaults unless the server is sized and load-tested for the high-traffic profile. HTTP/2 retains a 1024-stream flow-control guard per connection, while upload memory stays globally bounded. Saved in the panel config and applied live on restart/reload.":"XHTTP is handled as VPN tunnel traffic: packet requests use bounded backpressure and are never rejected by an HTTP request-rate ceiling. At the transport ceiling, new sockets wait in the kernel backlog instead of being reset. Keep the safe defaults unless the server is sized and load-tested for the high-traffic profile. HTTP/2 retains a 1024-stream flow-control guard per connection, while upload memory stays globally bounded. Saved in the panel config and applied live on restart/reload." }); Object.assign(I18N_TEXT["pt-BR"], { - "Sort by":"Ordenar por","Show":"Mostrar","All":"Todos","Offline":"Offline","Connections":"Conexões","Usage":"Uso","Quota reached":"Cota atingida","{visible} of {total} users":"{visible} de {total} usuários","No Xray users match this filter.":"Nenhum usuário Xray corresponde a este filtro.", + "Sort by":"Ordenar por","Show":"Mostrar","All":"Todos","Offline":"Offline","Connections":"Conexões","Usage":"Uso","Quota reached":"Cota atingida","{visible} of {total} users":"{visible} de {total} usuários","No Xray users match this filter.":"Nenhum usuário Xray corresponde a este filtro.","No SSH users match this filter.":"Nenhum usuário SSH corresponde a este filtro.", "Native Xray scale tuning":"Ajustes de escala do Xray nativo", "Go CPU threads (GOMAXPROCS)":"Threads de CPU do Go (GOMAXPROCS)", "Global mux backend sessions":"Sessões globais de backend Mux", diff --git a/admin/assets/js/03-ssh-users.js b/admin/assets/js/03-ssh-users.js index b431094..a5e468e 100644 --- a/admin/assets/js/03-ssh-users.js +++ b/admin/assets/js/03-ssh-users.js @@ -81,10 +81,33 @@ const USER_SORT_EXTRACT = { }; // Columns that default to descending on first click (most/online first). const USER_SORT_DEFAULT_DESC = new Set(["status", "conn", "max", "up", "down", "usage"]); +const USER_SORT_OPTIONS = [ + ["username", "User"], ["status", "Status"], ["auth", "Auth"], ["conn", "Connections"], + ["usage", "Usage"], ["expires", "Expiry"], ["max", "Max"], ["up", "Up"], ["down", "Dn"], + ["owner", "Owner"], +]; +const USER_FILTER_OPTIONS = [ + ["all", "All"], ["online", "Online"], ["offline", "Offline"], + ["active", "Active"], ["expired", "Expired"], ["quota", "Quota reached"], +]; let userSort = { key: "username", dir: "asc" }; +let userFilter = "all"; let lastUsersData = []; +function userMatchesFilter(user) { + const online = Number(user.active_conns || 0) > 0; + const expired = isExpiredDate(user.expires_at); + switch (userFilter) { + case "online": return online; + case "offline": return !online; + case "active": return !expired; + case "expired": return expired; + case "quota": return !!user.quota_exceeded; + default: return true; + } +} + function sortUsers(list) { const ext = USER_SORT_EXTRACT[userSort.key] || USER_SORT_EXTRACT.username; const dir = userSort.dir === "desc" ? -1 : 1; @@ -110,6 +133,44 @@ function updateSortIndicators() { }); } +function renderSSHListControls() { + const sortLabel = document.getElementById("sshSortLabel"); + const filterLabel = document.getElementById("sshFilterLabel"); + const sortButtons = document.getElementById("sshSortButtons"); + const filterButtons = document.getElementById("sshFilterButtons"); + const count = document.getElementById("sshListCount"); + if (sortLabel) sortLabel.textContent = t("Sort by"); + if (filterLabel) filterLabel.textContent = t("Show"); + if (sortButtons) { + const options = USER_SORT_OPTIONS.filter(([key]) => key !== "owner" || currentRole === "superadmin"); + sortButtons.replaceChildren(...options.map(([key, label]) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "user-list-filter-btn" + (userSort.key === key ? " active" : ""); + button.textContent = t(label); + button.setAttribute("aria-pressed", userSort.key === key ? "true" : "false"); + if (userSort.key === key) button.dataset.direction = userSort.dir; + button.addEventListener("click", () => setUserSort(key)); + return button; + })); + } + if (filterButtons) { + filterButtons.replaceChildren(...USER_FILTER_OPTIONS.map(([key, label]) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "user-list-filter-btn" + (userFilter === key ? " active" : ""); + button.textContent = t(label); + button.setAttribute("aria-pressed", userFilter === key ? "true" : "false"); + button.addEventListener("click", () => setUserFilter(key)); + return button; + })); + } + if (count) { + const visible = lastUsersData.filter(userMatchesFilter).length; + count.textContent = t("{visible} of {total} users", {visible, total:lastUsersData.length}); + } +} + function setUserSort(key) { if (!USER_SORT_EXTRACT[key]) return; if (userSort.key === key) { @@ -118,7 +179,12 @@ function setUserSort(key) { userSort.key = key; userSort.dir = USER_SORT_DEFAULT_DESC.has(key) ? "desc" : "asc"; } - updateSortIndicators(); + renderUsers(lastUsersData); +} + +function setUserFilter(filter) { + if (!USER_FILTER_OPTIONS.some(([key]) => key === filter)) return; + userFilter = filter; renderUsers(lastUsersData); } @@ -131,6 +197,8 @@ function setUserSort(key) { updateSortIndicators(); })(); +renderSSHListControls(); + function sshTrafficHTML(u) { const up = Number(u.total_uplink_bytes || 0); const down = Number(u.total_downlink_bytes || 0); @@ -146,19 +214,18 @@ function sshTrafficHTML(u) { function renderUsers(users) { // Cache the raw list so a header click can re-sort without refetching, and // order by the active column so rows don't shuffle on each live poll. - lastUsersData = users || []; - users = sortUsers(lastUsersData); - updateDashboardFromUsers(users); + lastUsersData = Array.isArray(users) ? users : []; + users = sortUsers(lastUsersData.filter(userMatchesFilter)); + updateDashboardFromUsers(lastUsersData); + renderSSHListControls(); + updateSortIndicators(); const isSA = currentRole === "superadmin"; - userCountChip.textContent = users.length; - if (isSA) ownerColHead.classList.remove("hidden"); + ownerColHead.classList.toggle("hidden", !isSA); usersBody.innerHTML = ""; - let online = 0; - let expiredCount = 0; + const online = lastUsersData.filter(u => Number(u.active_conns || 0) > 0).length; + const expiredCount = lastUsersData.filter(u => isExpiredDate(u.expires_at)).length; users.forEach(u => { const on = (u.active_conns || 0) > 0; - if (on) online++; - if (isExpiredDate(u.expires_at)) expiredCount++; const tr = document.createElement("tr"); const cells = [ u.username, @@ -198,9 +265,19 @@ function renderUsers(users) { tr.appendChild(tdA); usersBody.appendChild(tr); }); - const activeCount = Math.max(0, users.length - expiredCount); - userCountChip.textContent = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, online}); - if (sshMetricTotal) sshMetricTotal.textContent = String(users.length); + if (!users.length) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + cell.colSpan = isSA ? 11 : 10; + cell.className = "hint"; + cell.style.cssText = "padding:24px;text-align:center;"; + cell.textContent = t("No SSH users match this filter."); + row.appendChild(cell); + usersBody.appendChild(row); + } + const activeCount = Math.max(0, lastUsersData.length - expiredCount); + userCountChip.textContent = t("{count} total · {active} active · {online} online", {count:lastUsersData.length, active:activeCount, online}); + if (sshMetricTotal) sshMetricTotal.textContent = String(lastUsersData.length); if (sshMetricActive) sshMetricActive.textContent = String(activeCount); if (sshMetricOnline) sshMetricOnline.textContent = String(online); if (sshMetricState) sshMetricState.textContent = t("Online"); diff --git a/admin/assets/js/04-xray.js b/admin/assets/js/04-xray.js index 50b1c99..4e7d988 100644 --- a/admin/assets/js/04-xray.js +++ b/admin/assets/js/04-xray.js @@ -91,7 +91,7 @@ function renderXrayListControls() { sortButtons.replaceChildren(...XRAY_CLIENT_SORT_OPTIONS.map(([key, label]) => { const button = document.createElement("button"); button.type = "button"; - button.className = "xray-list-filter-btn" + (xrayClientSort.key === key ? " active" : ""); + button.className = "user-list-filter-btn" + (xrayClientSort.key === key ? " active" : ""); button.textContent = t(label); button.setAttribute("aria-pressed", xrayClientSort.key === key ? "true" : "false"); if (xrayClientSort.key === key) button.dataset.direction = xrayClientSort.dir; @@ -103,7 +103,7 @@ function renderXrayListControls() { filterButtons.replaceChildren(...XRAY_CLIENT_FILTER_OPTIONS.map(([key, label]) => { const button = document.createElement("button"); button.type = "button"; - button.className = "xray-list-filter-btn" + (xrayClientFilter === key ? " active" : ""); + button.className = "user-list-filter-btn" + (xrayClientFilter === key ? " active" : ""); button.textContent = t(label); button.setAttribute("aria-pressed", xrayClientFilter === key ? "true" : "false"); button.addEventListener("click", () => setXrayClientFilter(key)); diff --git a/admin/index.html b/admin/index.html index 53b3e0b..b6a9b4b 100644 --- a/admin/index.html +++ b/admin/index.html @@ -16,7 +16,7 @@ setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500); })(); - +