diff --git a/admin/assets/app.css b/admin/assets/app.css
index 5d007e8..382394e 100644
--- a/admin/assets/app.css
+++ b/admin/assets/app.css
@@ -164,8 +164,10 @@ body.light-mode{
}
.app{padding:0;background:linear-gradient(180deg,var(--bg),#101010);min-height:100vh;}
.shell{max-width:none;margin:0;background:transparent;border:0;border-radius:0;box-shadow:none;padding:0;min-height:100vh;}
-.panel-layout{display:grid;grid-template-columns:280px minmax(0,1fr);min-height:100vh;background:var(--bg);}
-.sidebar{position:sticky;top:0;height:100vh;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:20;box-shadow:18px 0 45px rgba(0,0,0,.18);}
+.panel-layout{display:block;min-height:100vh;background:var(--bg);}
+@supports (min-height:100dvh){.panel-layout{min-height:100dvh;}}
+.sidebar{position:fixed;left:0;top:0;bottom:0;width:280px;height:100vh;background:var(--panel);border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:20;box-shadow:18px 0 45px rgba(0,0,0,.18);}
+@supports (height:100dvh){.sidebar{height:100dvh;}}
.brand-block{height:112px;display:flex;align-items:center;gap:14px;padding:22px 22px;border-bottom:1px solid var(--border);}
.brand-mark{width:54px;height:54px;border-radius:18px;display:grid;place-items:center;background:linear-gradient(135deg,rgba(90,73,245,.18),rgba(174,47,243,.18));font-size:2rem;filter:drop-shadow(0 10px 18px rgba(0,0,0,.35));}
.brand-copy{display:flex;flex-direction:column;gap:2px;}
@@ -181,7 +183,8 @@ body.light-mode{
.avatar-dragon{width:42px;height:42px;border-radius:14px;background:#111;display:grid;place-items:center;font-size:1.45rem;}
.sidebar-foot strong{display:block;font-size:.93rem;}
.sidebar-foot span{display:block;color:var(--muted);font-size:.78rem;margin-top:2px;text-transform:capitalize;}
-.workspace{min-width:0;display:flex;flex-direction:column;background:radial-gradient(circle at 35% -10%,rgba(90,73,245,.12),transparent 32%),var(--bg);}
+.workspace{min-width:0;margin-left:280px;display:flex;flex-direction:column;min-height:100vh;background:radial-gradient(circle at 35% -10%,rgba(90,73,245,.12),transparent 32%),var(--bg);}
+@supports (min-height:100dvh){.workspace{min-height:100dvh;}}
.topbar{height:84px;display:flex;align-items:center;justify-content:space-between;gap:18px;padding:0 26px;border-bottom:1px solid var(--border);background:rgba(31,31,32,.86);backdrop-filter:blur(18px);position:sticky;top:0;z-index:15;margin:0;}
body.light-mode .topbar{background:rgba(255,255,255,.82);}
.topbar-left,.topbar-actions{display:flex;align-items:center;gap:14px;min-width:0;}
@@ -257,9 +260,10 @@ tbody tr:hover{background:rgba(90,73,245,.08);}
.badge-on{color:var(--success);font-weight:750}.badge-off{color:var(--muted);font-weight:700}
pre.log-box{background:#121214;border-color:var(--border);border-radius:15px;}
.drawer-backdrop{display:none;}
-@media(max-width:1180px){.dash-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.panel-layout{grid-template-columns:250px minmax(0,1fr);}}
+@media(max-width:1180px){.dash-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.sidebar{width:250px;}.workspace{margin-left:250px;}}
@media(max-width:820px){
- .panel-layout{grid-template-columns:1fr;}
+ .panel-layout{display:block;}
+ .workspace{margin-left:0;}
.sidebar{position:fixed;left:0;top:0;bottom:0;width:min(82vw,300px);height:100vh;transform:translateX(-105%);transition:transform .22s ease;}
body.sidebar-open .sidebar{transform:translateX(0);}
.drawer-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.58);z-index:18;backdrop-filter:blur(4px);}
@@ -327,3 +331,7 @@ pre.log-box{background:#121214;border-color:var(--border);border-radius:15px;}
.dash-icon{width:54px;height:54px;font-size:1.35rem;}
.btn{padding:9px 11px;}
}
+
+/* Xray client table updates in place; this tiny state avoids visual flicker during background polling. */
+#inboundsContainer.xray-refreshing{opacity:.985;}
+#inboundsContainer [data-cell]{transition:background-color .12s ease;}
diff --git a/admin/assets/app.js b/admin/assets/app.js
index 86c0bcd..192c5e5 100644
--- a/admin/assets/app.js
+++ b/admin/assets/app.js
@@ -10,6 +10,8 @@ let editingXrayClientId = null;
let wzInbounds = [];
let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null };
let currentTab = "dashboard";
+let inboundsRefreshInFlight = false;
+let lastInboundsStructure = "";
// ─── DOM refs ─────────────────────────────────────────────────────────────────
const loginOverlay = document.getElementById("loginOverlay");
@@ -194,6 +196,92 @@ function genUUID() {
(c^crypto.getRandomValues(new Uint8Array(1))[0]&15>>c/4).toString(16));
}
+
+function escapeHTML(value) {
+ return String(value ?? "").replace(/[&<>'"]/g, ch => ({
+ "&": "&", "<": "<", ">": ">", "'": "'", '"': """,
+ }[ch]));
+}
+
+function isVisible(el) {
+ return !!el && !el.classList.contains("hidden") && getComputedStyle(el).display !== "none";
+}
+
+function isXrayClientEditorActive() {
+ const active = document.activeElement;
+ if (active && active.closest && (active.closest("#inboundsContainer") || active.closest("#editXrayClientPanel"))) return true;
+ if (isVisible(document.getElementById("editXrayClientPanel"))) return true;
+ return Array.from(document.querySelectorAll('#inboundsContainer [id^="add-form-"]')).some(isVisible);
+}
+
+function inboundStructure(inbounds = []) {
+ return JSON.stringify((inbounds || []).map(ib => ({
+ tag: ib.tag || "",
+ protocol: ib.protocol || "",
+ port: ib.port ?? "",
+ clients: (ib.clients || []).map(c => c.id || ""),
+ })));
+}
+
+function clientStatusHTML(c) {
+ const exp = c.expires_at ? new Date(c.expires_at) : null;
+ const daysLeft = c.expiration_days;
+ if (c.expired) return `Expired`;
+ if (daysLeft === -1 || !exp) return `Active`;
+ return `Active (${escapeHTML(daysLeft)}d)`;
+}
+
+function clientExpiryLabel(c) {
+ const exp = c.expires_at ? new Date(c.expires_at) : null;
+ return exp ? exp.toLocaleDateString() : "Unlimited";
+}
+
+function clientOnlineHTML(c) {
+ return `${c.online ? 'online' : 'offline'}
${escapeHTML(formatLastActive(c.last_active))}
`;
+}
+
+function updateCell(row, name, html) {
+ const cell = row?.querySelector?.(`[data-cell="${name}"]`);
+ if (cell && cell.innerHTML !== html) cell.innerHTML = html;
+}
+
+function patchRenderedInbounds(inbounds) {
+ const sections = Array.from(inboundsContainer.querySelectorAll("[data-inbound-tag]"));
+ if (sections.length !== inbounds.length) return false;
+
+ for (const ib of inbounds) {
+ const tag = String(ib.tag || "");
+ const section = sections.find(el => el.dataset.inboundTag === tag);
+ if (!section) return false;
+ if (section.dataset.inboundProtocol !== String(ib.protocol || "") || section.dataset.inboundPort !== String(ib.port ?? "")) return false;
+
+ const clients = ib.clients || [];
+ const rows = Array.from(section.querySelectorAll("tr[data-client-id]"));
+ if (rows.length !== clients.length) return false;
+
+ const onlineCount = clients.filter(c => !!c.online).length;
+ const chip = section.querySelector('[data-role="inbound-online-chip"]');
+ if (chip) {
+ chip.textContent = `${onlineCount} online`;
+ chip.classList.toggle("green", onlineCount > 0);
+ }
+
+ for (const c of clients) {
+ const row = rows.find(el => el.dataset.clientId === String(c.id || ""));
+ if (!row) return false;
+ updateCell(row, "name", escapeHTML(c.name || "—"));
+ updateCell(row, "uuid", escapeHTML(c.id || "—"));
+ updateCell(row, "email", escapeHTML(c.email || "—"));
+ updateCell(row, "expiry", escapeHTML(clientExpiryLabel(c)));
+ updateCell(row, "status", clientStatusHTML(c));
+ updateCell(row, "online", clientOnlineHTML(c));
+ updateCell(row, "traffic", escapeHTML(formatBytes(c.total_bytes)));
+ updateCell(row, "max", escapeHTML(c.max_conns || "∞"));
+ }
+ }
+ return true;
+}
+
// ─── Navigation / shell ──────────────────────────────────────────────────────
const tabTitles = {
dashboard: ["Painel", "Visão geral"],
@@ -223,7 +311,7 @@ function selectTab(tab) {
if (tab === "dashboard") refreshDashboard();
if (tab === "xray") {
loadXrayStatus();
- loadInbounds();
+ loadInbounds({ force: true });
if (currentRole === "superadmin") loadWizardFromConfig();
}
if (tab === "stats" && currentRole === "superadmin") loadStats();
@@ -321,11 +409,14 @@ function initAfterLogin() {
} else {
loadMe();
}
- xrayTimer = setInterval(() => { loadXrayStatus(); if (currentTab === "xray") loadInbounds(); }, 7000);
+ xrayTimer = setInterval(() => {
+ loadXrayStatus();
+ if (currentTab === "xray") loadInbounds({ silent: true });
+ }, 7000);
loadUsers();
loadXrayStatus();
- loadInbounds();
+ loadInbounds({ silent: true });
usersTimer = setInterval(() => loadUsersSilent(), 3000);
}
@@ -482,7 +573,7 @@ function updateDashboardXray(inbounds = []) {
function refreshDashboard() {
loadUsersSilent();
- loadInbounds();
+ loadInbounds({ silent: true });
loadXrayStatus();
if (currentRole === "superadmin") loadStats();
if (currentRole === "reseller") loadMe();
@@ -651,17 +742,12 @@ document.getElementById("xStartBtn").addEventListener("click", () => xrayCtrl("s
document.getElementById("xStopBtn").addEventListener("click", () => xrayCtrl("stop"));
document.getElementById("xRestartBtn").addEventListener("click", () => xrayCtrl("restart"));
document.getElementById("xRepairStatsBtn")?.addEventListener("click", repairXrayStats);
-document.getElementById("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds(); });
-document.getElementById("xLoadInboundsBtn").addEventListener("click", loadInbounds);
+document.getElementById("xRefreshBtn").addEventListener("click", () => { loadXrayStatus(); loadInbounds({ force: true }); });
+document.getElementById("xLoadInboundsBtn").addEventListener("click", () => loadInbounds({ force: true }));
document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
-document.querySelector("[data-tab='xray']")?.addEventListener("click", () => {
- loadXrayStatus();
- loadInbounds();
- if (currentRole === "superadmin") loadWizardFromConfig();
-});
async function loadXrayStatus() {
try {
@@ -709,7 +795,7 @@ async function repairXrayStats() {
? (d.restarted ? "Counters API repaired and Xray restarted." : "Counters API repaired. Restart Xray to apply it.")
: "Counters API already looks correct.";
setTimeout(loadXrayStatus, 700);
- setTimeout(loadInbounds, 1200);
+ setTimeout(() => loadInbounds({ force: true }), 1200);
} catch (e) {
if (e.message==="auth") doAuthError();
else xStatus.textContent = "Error repairing counters: "+e.message;
@@ -725,22 +811,30 @@ async function xrayCtrl(action) {
if (!res.ok) throw new Error(await res.text());
xStatus.textContent = "Xray "+action+" OK.";
setTimeout(loadXrayStatus, 700);
- setTimeout(loadInbounds, 1200);
+ setTimeout(() => loadInbounds({ force: true }), 1200);
} catch (e) {
if (e.message==="auth") doAuthError();
else xStatus.textContent = "Error: "+e.message;
}
}
-async function loadInbounds() {
- inboundsContainer.innerHTML = 'Loading…
';
+async function loadInbounds(options = {}) {
+ const { silent = false, force = false } = options || {};
+ if (inboundsRefreshInFlight) return;
+ inboundsRefreshInFlight = true;
+ if (!silent) inboundsContainer.innerHTML = 'Loading…
';
+ else inboundsContainer.classList.add("xray-refreshing");
try {
const res = await api("/api/xray/inbounds");
+ if (!res.ok) throw new Error(await res.text());
const inbounds = await res.json();
- renderInbounds(inbounds || []);
+ renderInbounds(inbounds || [], { silent, force });
} catch (e) {
- inboundsContainer.textContent = "Error loading inbounds.";
+ if (!silent) inboundsContainer.textContent = "Error loading inbounds.";
if (e.message==="auth") doAuthError();
+ } finally {
+ inboundsRefreshInFlight = false;
+ inboundsContainer.classList.remove("xray-refreshing");
}
}
@@ -762,15 +856,30 @@ async function copyText(text) {
finally { document.body.removeChild(ta); }
}
-function renderInbounds(inbounds) {
+function renderInbounds(inbounds, options = {}) {
+ const { silent = false, force = false } = options || {};
updateDashboardXray(inbounds);
+ const nextStructure = inboundStructure(inbounds);
+
+ if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(inbounds)) return;
+ if (silent && !force && isXrayClientEditorActive()) {
+ patchRenderedInbounds(inbounds);
+ if (xStatus) xStatus.textContent = "New client data is available; editing was preserved.";
+ return;
+ }
+
if (!inbounds.length) {
inboundsContainer.innerHTML = 'No VLESS/VMess/Trojan inbounds found.
';
+ lastInboundsStructure = nextStructure;
return;
}
inboundsContainer.innerHTML = "";
+ lastInboundsStructure = nextStructure;
inbounds.forEach(ib => {
const section = document.createElement("div");
+ section.dataset.inboundTag = String(ib.tag || "");
+ section.dataset.inboundProtocol = String(ib.protocol || "");
+ section.dataset.inboundPort = String(ib.port ?? "");
section.style = "margin-bottom:14px;";
const hdr = document.createElement("div");
@@ -780,10 +889,10 @@ function renderInbounds(inbounds) {
const onlineCount = clients.filter(c => !!c.online).length;
hdr.innerHTML = `
- ${ib.protocol}
- ${ib.tag || "untagged"}
- :${ib.port ?? "?"}
- ${onlineCount} online
+ ${escapeHTML(ib.protocol)}
+ ${escapeHTML(ib.tag || "untagged")}
+ :${escapeHTML(ib.port ?? "?")}
+ ${onlineCount} online
`;
section.appendChild(hdr);
@@ -823,28 +932,17 @@ function renderInbounds(inbounds) {
tbl.innerHTML = `| Name | UUID | Email | Expiry | Status | Online | Traffic | Max | Actions |
`;
const tbody = document.createElement("tbody");
clients.forEach(c => {
- const exp = c.expires_at ? new Date(c.expires_at) : null;
- const expStr = exp ? exp.toLocaleDateString() : "Unlimited";
- const isExpired = !!c.expired;
- const daysLeft = c.expiration_days;
- let statusHtml;
- if (isExpired) {
- statusHtml = `Expired`;
- } else if (daysLeft === -1 || !exp) {
- statusHtml = `Active`;
- } else {
- statusHtml = `Active (${daysLeft}d)`;
- }
const tr = document.createElement("tr");
+ tr.dataset.clientId = String(c.id || "");
tr.innerHTML = `
- ${c.name || "—"} |
- ${c.id} |
- ${c.email || "—"} |
- ${expStr} |
- ${statusHtml} |
- ${c.online ? 'online' : 'offline'} ${formatLastActive(c.last_active)} |
- ${formatBytes(c.total_bytes)} |
- ${c.max_conns || "∞"} | `;
+ ${escapeHTML(c.name || "—")} |
+ ${escapeHTML(c.id || "—")} |
+ ${escapeHTML(c.email || "—")} |
+ ${escapeHTML(clientExpiryLabel(c))} |
+ ${clientStatusHTML(c)} |
+ ${clientOnlineHTML(c)} |
+ ${escapeHTML(formatBytes(c.total_bytes))} |
+ ${escapeHTML(c.max_conns || "∞")} | `;
const actTd = document.createElement("td");
actTd.style.whiteSpace = "nowrap";
const copyBtn = document.createElement("button");
@@ -904,7 +1002,7 @@ async function addClient(tag) {
});
if (!res.ok) throw new Error(await res.text());
xStatus.textContent = `Client ${uuid.slice(0,8)}… added. Restarting Xray…`;
- setTimeout(() => { loadInbounds(); if (currentRole === "reseller") loadMe(); }, 1500);
+ setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
} catch (e) {
if (e.message==="auth") doAuthError();
else xStatus.textContent = "Error: "+e.message;
@@ -917,7 +1015,7 @@ async function removeClient(tag, uuid) {
const res = await api(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, { method:"DELETE" });
if (!res.ok && res.status !== 204) throw new Error(await res.text());
xStatus.textContent = "Client removed. Restarting Xray…";
- setTimeout(() => { loadInbounds(); if (currentRole === "reseller") loadMe(); }, 1500);
+ setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
} catch (e) {
if (e.message==="auth") doAuthError();
else xStatus.textContent = "Error: "+e.message;
@@ -1639,7 +1737,7 @@ async function saveEditXrayClient() {
const res = await api("/api/xray/clients/update", { method:"POST", body: JSON.stringify(payload) });
if (!res.ok) throw new Error(await res.text());
st.textContent = "Saved.";
- setTimeout(() => { closeEditXrayClient(); loadInbounds(); }, 700);
+ setTimeout(() => { closeEditXrayClient(); loadInbounds({ force: true }); }, 700);
} catch (e) {
if (e.message==="auth") doAuthError();
else st.textContent = "Error: " + e.message;
diff --git a/admin/index.html b/admin/index.html
index 7cd98f5..9e215fc 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -4,7 +4,7 @@
DragonCore Panel
-
+
@@ -876,6 +876,6 @@
-
+