Align native Xray with xray-core; drop dead knobs; split admin app.js
Fix the two reliability problems in the in-process Xray emulator by matching XTLS/Xray-core's transport semantics: - XHTTP upload queue: rewrite as a faithful port of xray-core's uploadQueue (bounded channel + sequence reorder heap). Packet-up POSTs are now acked immediately on buffering instead of blocking until the tunnel reader consumes them. The old block-until-consumed behavior throttled the uplink to the reassembly rate and deadlocked against the client's concurrent-POST limit, which showed up as "download a burst, stall, repeat" on video/large downloads. - Mux: dial the backend and pump uplink on a per-session goroutine fed by a bounded channel (mirrors xray-core's per-session buffered pipe). Previously the dial and backend writes ran inline in the shared read loop, so one slow target or backpressured session stalled every other muxed session. - XHTTP download writer: flush every write (matches httpServerConn.Write) instead of batching behind a 2ms/32KB window. - XHTTP: enforce a single download (stream-down) per session to stop two GETs from splitting the decoded stream and corrupting the tunnel. - Fix a close-of-closed-channel race in the mux session teardown (sync.Once). Remove the now-inert XHTTP tuning knobs (xhttp_queue_timeout_ms, xhttp_flush_ms, xhttp_flush_bytes) from the backend struct and the admin panel UI. Split admin/assets/app.js into ordered classic-script modules under admin/assets/js/ for maintainability. The concatenation is byte-identical to the old file and load order is preserved via defer, so behavior is unchanged. Add regression tests for the mux head-of-line stall and the out-of-order packet-up burst-stall; add golang.org/x/text to go.mod so tests build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
// ─── Resellers ────────────────────────────────────────────────────────────────
|
||||
document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers);
|
||||
document.getElementById("newResellerBtn").addEventListener("click", () => {
|
||||
resellerFormTitle.textContent = "Create Reseller";
|
||||
resellerForm.reset();
|
||||
rActive.checked = true;
|
||||
resellerStatus.textContent = "New reseller.";
|
||||
});
|
||||
document.getElementById("cancelResellerBtn").addEventListener("click", () => {
|
||||
resellerForm.reset();
|
||||
rActive.checked = true;
|
||||
resellerFormTitle.textContent = "Create Reseller";
|
||||
});
|
||||
|
||||
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
|
||||
|
||||
async function loadResellers() {
|
||||
resellerStatus.textContent = "Loading…";
|
||||
try {
|
||||
const res = await api("/api/resellers");
|
||||
const data = await res.json();
|
||||
renderResellers(data || []);
|
||||
resellerStatus.textContent = "Loaded.";
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error loading.";
|
||||
}
|
||||
}
|
||||
|
||||
function renderResellers(list) {
|
||||
resellerCountChip.textContent = list.length;
|
||||
resellersBody.innerHTML = "";
|
||||
list.forEach(r => {
|
||||
const expired = r.expires_at && new Date(r.expires_at) < new Date();
|
||||
const max = r.max_users || 0;
|
||||
const used = r.used_users || 0;
|
||||
const remaining = max ? Math.max(0, max - used) : "∞";
|
||||
const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0;
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${r.username}</td>
|
||||
<td>
|
||||
<strong>${used} / ${max || "∞"}</strong>
|
||||
<div class="hint">Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}</div>
|
||||
<div class="table-meter"><span style="width:${pct}%"></span></div>
|
||||
</td>
|
||||
<td>${r.expires_at ? fmtDate(r.expires_at) : "—"}</td>
|
||||
<td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td>
|
||||
<td></td>`;
|
||||
const tdA = tr.lastElementChild;
|
||||
const editBtn = Object.assign(document.createElement("button"),{
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
onclick: () => fillResellerForm(r),
|
||||
});
|
||||
const delBtn = Object.assign(document.createElement("button"),{
|
||||
className:"btn btn-danger btn-sm", textContent:t("Del"),
|
||||
style: "margin-left:4px;",
|
||||
onclick: () => deleteReseller(r.username),
|
||||
});
|
||||
tdA.append(editBtn, delBtn);
|
||||
resellersBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function fillResellerForm(r) {
|
||||
resellerFormTitle.textContent = `Edit: ${r.username}`;
|
||||
rUsername.value = r.username;
|
||||
rPassword.value = "";
|
||||
rMaxUsers.value = r.max_users || 0;
|
||||
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
|
||||
rActive.checked = r.is_active;
|
||||
resellerStatus.textContent = `Editing ${r.username}.`;
|
||||
}
|
||||
|
||||
resellerForm.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("saveResellerBtn");
|
||||
btn.disabled = true;
|
||||
resellerStatus.textContent = "Saving…";
|
||||
const payload = {
|
||||
username: rUsername.value.trim(),
|
||||
password: rPassword.value || undefined,
|
||||
max_users: parseInt(rMaxUsers.value||"0",10),
|
||||
expires_at: isoFromLocal(rExpires.value),
|
||||
is_active: rActive.checked,
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/resellers/create", { method:"POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
resellerStatus.textContent = "Saved.";
|
||||
resellerForm.reset(); rActive.checked = true;
|
||||
resellerFormTitle.textContent = "Create Reseller";
|
||||
loadResellers();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error: "+e.message;
|
||||
} finally { btn.disabled = false; }
|
||||
});
|
||||
|
||||
async function deleteReseller(username) {
|
||||
if (!confirm(`Delete reseller "${username}"? All their SSH sessions will be disconnected.`)) return;
|
||||
resellerStatus.textContent = `Deleting ${username}…`;
|
||||
try {
|
||||
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error("failed");
|
||||
resellerStatus.textContent = "Deleted.";
|
||||
loadResellers();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error deleting.";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user