diff --git a/README.md b/README.md index e90883d..3a48ed9 100644 --- a/README.md +++ b/README.md @@ -15,6 +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 - 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 941692d..9cd2d86 100644 --- a/admin/assets/app.css +++ b/admin/assets/app.css @@ -757,3 +757,8 @@ th[data-sort-key]:hover{color:var(--accent);} th[data-sort-key]::after{content:"";display:inline-block;width:.9em;font-size:.72em;opacity:.85;} 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}} diff --git a/admin/assets/js/01-core.js b/admin/assets/js/01-core.js index 9af8133..f70c6bb 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.renderXrayListControls === "function") window.renderXrayListControls(); document.documentElement.classList.remove("i18n-pending"); } @@ -744,6 +745,7 @@ function patchRenderedInbounds(inbounds) { updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c))); updateCell(row, "status", clientStatusHTML(c)); updateCell(row, "online", clientOnlineHTML(c)); + updateCell(row, "connections", escapeHTML(c.active_connections || 0)); updateCell(row, "traffic", clientTrafficHTML(c)); updateCell(row, "max", escapeHTML(c.max_conns || "∞")); } @@ -754,6 +756,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.", "Native Xray scale tuning":"Native Xray scale tuning", "Go CPU threads (GOMAXPROCS)":"Go CPU threads (GOMAXPROCS)", "Global mux backend sessions":"Global mux backend sessions", @@ -768,6 +771,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.", "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/04-xray.js b/admin/assets/js/04-xray.js index 2452c2e..50b1c99 100644 --- a/admin/assets/js/04-xray.js +++ b/admin/assets/js/04-xray.js @@ -18,6 +18,133 @@ document.getElementById("xCreateUUIDBtn")?.addEventListener("click", () => { document.getElementById("xCreateInbound")?.addEventListener("change", updateXrayCreatorInboundLabel); document.getElementById("xCreateClientForm")?.addEventListener("submit", submitXrayClientCreator); +// Keep Xray list controls consistent with the sortable SSH user table while +// preserving the inbound grouping. Sorting is applied inside each inbound; +// filters apply across all inbound groups. +const XRAY_CLIENT_SORT_EXTRACT = { + name: c => String(c.name || c.email || c.id || "").toLowerCase(), + status: c => c.expired ? 0 : 1, + online: c => c.online ? 1 : 0, + connections: c => Number(c.active_connections || 0), + usage: c => Number(c.total_bytes || ((c.uplink_bytes || 0) + (c.downlink_bytes || 0)) || 0), + expiry: c => c.expires_at ? new Date(c.expires_at).getTime() : Infinity, + max: c => Number(c.max_conns || 0), +}; +const XRAY_CLIENT_SORT_DEFAULT_DESC = new Set(["status", "online", "connections", "usage", "max"]); +const XRAY_CLIENT_SORT_OPTIONS = [ + ["name", "Name"], ["status", "Status"], ["online", "Online"], + ["connections", "Connections"], ["usage", "Usage"], ["expiry", "Expiry"], ["max", "Max"], +]; +const XRAY_CLIENT_FILTER_OPTIONS = [ + ["all", "All"], ["online", "Online"], ["offline", "Offline"], + ["active", "Active"], ["expired", "Expired"], ["quota", "Quota reached"], +]; +let xrayClientSort = { key: "name", dir: "asc" }; +let xrayClientFilter = "all"; +let lastXrayInboundsData = []; + +function xrayClientMatchesFilter(client) { + switch (xrayClientFilter) { + case "online": return !!client.online; + case "offline": return !client.online; + case "active": return !client.expired; + case "expired": return !!client.expired; + case "quota": return !!client.quota_exceeded; + default: return true; + } +} + +function sortXrayClients(clients = []) { + const extract = XRAY_CLIENT_SORT_EXTRACT[xrayClientSort.key] || XRAY_CLIENT_SORT_EXTRACT.name; + const direction = xrayClientSort.dir === "desc" ? -1 : 1; + return clients.slice().sort((a, b) => { + const left = extract(a), right = extract(b); + let comparison; + if (typeof left === "number" && typeof right === "number") comparison = left - right; + else comparison = String(left).localeCompare(String(right)); + if (comparison === 0) comparison = String(a.name || a.email || a.id || "").localeCompare(String(b.name || b.email || b.id || "")); + return comparison * direction; + }); +} + +function prepareXrayInboundsForList(inbounds = []) { + return (inbounds || []).map(inbound => ({ + ...inbound, + clients: sortXrayClients((inbound.clients || []).filter(xrayClientMatchesFilter)), + })).filter(inbound => xrayClientFilter === "all" || inbound.clients.length > 0); +} + +function xrayClientListCounts(inbounds = []) { + const clients = (inbounds || []).flatMap(inbound => inbound.clients || []); + return { total: clients.length, visible: clients.filter(xrayClientMatchesFilter).length }; +} + +function renderXrayListControls() { + const sortLabel = document.getElementById("xraySortLabel"); + const filterLabel = document.getElementById("xrayFilterLabel"); + const sortButtons = document.getElementById("xraySortButtons"); + const filterButtons = document.getElementById("xrayFilterButtons"); + const count = document.getElementById("xrayListCount"); + if (sortLabel) sortLabel.textContent = t("Sort by"); + if (filterLabel) filterLabel.textContent = t("Show"); + if (sortButtons) { + 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.textContent = t(label); + button.setAttribute("aria-pressed", xrayClientSort.key === key ? "true" : "false"); + if (xrayClientSort.key === key) button.dataset.direction = xrayClientSort.dir; + button.addEventListener("click", () => setXrayClientSort(key)); + return button; + })); + } + if (filterButtons) { + 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.textContent = t(label); + button.setAttribute("aria-pressed", xrayClientFilter === key ? "true" : "false"); + button.addEventListener("click", () => setXrayClientFilter(key)); + return button; + })); + } + if (count) { + const counts = xrayClientListCounts(lastXrayInboundsData); + count.textContent = t("{visible} of {total} users", counts); + } +} + +function setXrayClientSort(key) { + if (!XRAY_CLIENT_SORT_EXTRACT[key]) return; + if (xrayClientSort.key === key) xrayClientSort.dir = xrayClientSort.dir === "asc" ? "desc" : "asc"; + else { + xrayClientSort.key = key; + xrayClientSort.dir = XRAY_CLIENT_SORT_DEFAULT_DESC.has(key) ? "desc" : "asc"; + } + renderInbounds(lastXrayInboundsData, { force:true, fromControls:true }); +} + +function setXrayClientFilter(filter) { + if (!XRAY_CLIENT_FILTER_OPTIONS.some(([key]) => key === filter)) return; + xrayClientFilter = filter; + renderInbounds(lastXrayInboundsData, { force:true, fromControls:true }); +} + +function bindXrayTableSortHeaders(table) { + table.querySelectorAll("th[data-sort-key]").forEach(header => { + const key = header.dataset.sortKey; + const selected = key === xrayClientSort.key; + header.classList.toggle("sort-asc", selected && xrayClientSort.dir === "asc"); + header.classList.toggle("sort-desc", selected && xrayClientSort.dir === "desc"); + header.setAttribute("aria-sort", selected ? (xrayClientSort.dir === "asc" ? "ascending" : "descending") : "none"); + header.addEventListener("click", () => setXrayClientSort(key)); + }); +} + +renderXrayListControls(); + async function loadXrayStatus() { if (xrayChip) { @@ -167,25 +294,33 @@ async function copyText(text) { function renderInbounds(inbounds, options = {}) { const { silent = false, force = false } = options || {}; - updateDashboardXray(inbounds); - syncXrayCreatorInbounds(inbounds); - const nextStructure = inboundStructure(inbounds); + lastXrayInboundsData = Array.isArray(inbounds) ? inbounds : []; + updateDashboardXray(lastXrayInboundsData); + syncXrayCreatorInbounds(lastXrayInboundsData); + const listInbounds = prepareXrayInboundsForList(lastXrayInboundsData); + const nextStructure = inboundStructure(listInbounds); + renderXrayListControls(); - if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(inbounds)) return; + if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(listInbounds)) return; if (silent && !force && isXrayClientEditorActive()) { - patchRenderedInbounds(inbounds); + patchRenderedInbounds(listInbounds); if (xStatus) xStatus.textContent = t("New client data is available; editing was preserved."); return; } - if (!inbounds.length) { + if (!lastXrayInboundsData.length) { inboundsContainer.innerHTML = `