Fix admin panel
This commit is contained in:
@@ -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}}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+145
-8
@@ -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 = `<div class="hint" style="padding:8px 0;">${t("No VLESS/VMess/Trojan inbounds found.")}</div>`;
|
||||
lastInboundsStructure = nextStructure;
|
||||
return;
|
||||
}
|
||||
if (!listInbounds.length) {
|
||||
inboundsContainer.innerHTML = `<div class="hint" style="padding:8px 0;">${t("No Xray users match this filter.")}</div>`;
|
||||
lastInboundsStructure = nextStructure;
|
||||
return;
|
||||
}
|
||||
inboundsContainer.innerHTML = "";
|
||||
lastInboundsStructure = nextStructure;
|
||||
inbounds.forEach(ib => {
|
||||
listInbounds.forEach(ib => {
|
||||
const section = document.createElement("div");
|
||||
section.dataset.inboundTag = String(ib.tag || "");
|
||||
section.dataset.inboundProtocol = String(ib.protocol || "");
|
||||
@@ -219,7 +354,7 @@ function renderInbounds(inbounds, options = {}) {
|
||||
tblWrap.innerHTML = `<div class="hint" style="padding:4px 0;">${t("No clients.")}</div>`;
|
||||
} else {
|
||||
const tbl = document.createElement("table");
|
||||
tbl.innerHTML = `<thead><tr><th>${t("Name")}</th><th>UUID</th><th>${t("Email")}</th><th>${t("Expiry")}</th><th>${t("Status")}</th><th>${t("Online")}</th><th>${t("Traffic")}</th><th>${t("Max")}</th><th>${t("Actions")}</th></tr></thead>`;
|
||||
tbl.innerHTML = `<thead><tr><th data-sort-key="name">${t("Name")}</th><th>UUID</th><th>${t("Email")}</th><th data-sort-key="expiry">${t("Expiry")}</th><th data-sort-key="status">${t("Status")}</th><th data-sort-key="online">${t("Online")}</th><th data-sort-key="connections">${t("Conn")}</th><th data-sort-key="usage">${t("Traffic")}</th><th data-sort-key="max">${t("Max")}</th><th>${t("Actions")}</th></tr></thead>`;
|
||||
const tbody = document.createElement("tbody");
|
||||
clients.forEach(c => {
|
||||
const tr = document.createElement("tr");
|
||||
@@ -231,6 +366,7 @@ function renderInbounds(inbounds, options = {}) {
|
||||
<td data-cell="expiry" style="font-size:.7rem;">${escapeHTML(clientExpiryLabel(c))}</td>
|
||||
<td data-cell="status">${clientStatusHTML(c)}</td>
|
||||
<td data-cell="online">${clientOnlineHTML(c)}</td>
|
||||
<td data-cell="connections" style="font-size:.7rem;">${escapeHTML(c.active_connections || 0)}</td>
|
||||
<td data-cell="traffic" style="font-size:.7rem;">${clientTrafficHTML(c)}</td>
|
||||
<td data-cell="max" style="font-size:.7rem;">${escapeHTML(c.max_conns || "∞")}</td>`;
|
||||
const actTd = document.createElement("td");
|
||||
@@ -260,6 +396,7 @@ function renderInbounds(inbounds, options = {}) {
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
tbl.appendChild(tbody);
|
||||
bindXrayTableSortHeaders(tbl);
|
||||
tblWrap.appendChild(tbl);
|
||||
}
|
||||
section.appendChild(tblWrap);
|
||||
|
||||
+14
-3
@@ -16,7 +16,7 @@
|
||||
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260714pamfix1"/>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260720xraylist1"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -420,6 +420,17 @@
|
||||
<div class="card-title">Inbounds & Clients</div>
|
||||
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadInboundsBtn">Reload</button></div>
|
||||
</div>
|
||||
<div class="xray-list-controls" id="xrayListControls" aria-label="Xray user list controls">
|
||||
<div class="xray-list-control-group">
|
||||
<span class="xray-list-control-label" id="xraySortLabel"></span>
|
||||
<div class="xray-list-buttons" id="xraySortButtons"></div>
|
||||
</div>
|
||||
<div class="xray-list-control-group">
|
||||
<span class="xray-list-control-label" id="xrayFilterLabel"></span>
|
||||
<div class="xray-list-buttons" id="xrayFilterButtons"></div>
|
||||
</div>
|
||||
<span class="chip xray-list-count" id="xrayListCount"></span>
|
||||
</div>
|
||||
<div id="inboundsContainer">
|
||||
<div class="hint" style="padding:8px 0;">Loading inbounds…</div>
|
||||
</div>
|
||||
@@ -1558,10 +1569,10 @@
|
||||
<!-- app.js was split into ordered modules for maintainability. They are plain
|
||||
classic scripts sharing one global scope; `defer` preserves execution order,
|
||||
so behavior is identical to the old single file. Keep this load order. -->
|
||||
<script defer src="assets/js/01-core.js?v=20260719xhttp502fix1"></script>
|
||||
<script defer src="assets/js/01-core.js?v=20260720xraylist1"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260714pamfix1"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260719quotaaudit1"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260715quotareset1"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260720xraylist1"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260714pamfix1"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260714pamfix1"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260714pamfix1"></script>
|
||||
|
||||
Reference in New Issue
Block a user