fix: enhance WebSocket stability, resolve XHTTP configurations and fix UI loading shifts (#3997)

* feat: implement real-time traffic monitoring and UI updates using a high-performance WebSocket hub and background job system

* feat: add bulk client management support and improve inbound data handling

* Fix bug

* **Fixes & Changes:**
1. **Fixed XPadding Placement Dropdown**:
   - Added the missing `cookie` and `query` options to `xPaddingPlacement` (`stream_xhttp.html`).
   - *Why:* Previously, users wanting `cookie` obfuscation were forced to use the `header` placement string. This caused Xray-core to blindly intercept the entire monolithic HTTP Cookie header, failing internal padding-length validations and causing the inbound to silently drop the connection.
2. **Fixed Uplink Data Placement Validation**:
   - Replaced the unsupported `query` option with `cookie` in `uplinkDataPlacement`.
   - *Why:* Xray-core's `transport_internet.go` explicitly forbids `query` as an uplink placement option. Selecting it from the UI previously sent a payload that would cause Xray-core to instantly throw an `unsupported uplink data placement: query` panic. Adding `cookie` perfectly aligns the UI with Xray-core restrictions.
### Related Issues
- Resolves #3992

* This commit fixes structural payload issues preventing XHTTP from functioning correctly and eliminates WebSocket log spam.
- **[Fix X-Padding UI]** Added missing `cookie` and `query` options to X-Padding Placement. Fixes the issue where using Cookie fallback triggers whole HTTP Cookie header interception and silent drop in Xray-core. (Resolves [#3992](https://github.com/MHSanaei/3x-ui/issues/3992))
- **[Fix Uplink Data Options]** Replaced the invalid `query` option with `cookie` in Uplink Data Placement dropdown to prevent Xray-core backend panic `unsupported uplink data placement: query`.
- **[Fix WebSockets Spam]** Boosted `maxMessageSize` boundary to 100MB and gracefully handled fallback fetch signals via `broadcastInvalidate` to avoid buffer dropping spam. (Resolves [#3984](https://github.com/MHSanaei/3x-ui/issues/3984))

* Fix

* gofmt

* fix(websocket): resolve channel race condition and graceful shutdown deadlock

* Fix: inbounds switch

* Change max quantity from 10000 to 500

* fix
This commit is contained in:
lolka1333
2026-04-19 22:01:00 +03:00
committed by GitHub
parent e02f78ac68
commit fec714a243
16 changed files with 291 additions and 132 deletions
+115 -37
View File
@@ -6,7 +6,7 @@
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}' size="large">
<transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
@@ -14,10 +14,7 @@
</transition>
<transition name="list" appear>
<a-row v-if="!loadingStates.fetched">
<a-card
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card>
<div :style="{ minHeight: 'calc(100vh - 120px)' }"></div>
</a-row>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-else>
<a-col>
@@ -1101,7 +1098,10 @@
}
data.sniffing = inbound.sniffing.toString();
await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal);
const formData = new FormData();
Object.keys(data).forEach(key => formData.append(key, data[key]));
await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, formData, inModal);
},
openAddClient(dbInboundId) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
@@ -1291,9 +1291,36 @@
infoModal.show(newDbInbound, index);
},
switchEnable(dbInboundId, state) {
dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
let dbInbound = this.dbInbounds.find(row => row.id === dbInboundId);
if (!dbInbound) return;
dbInbound.enable = state;
this.submit(`/panel/api/inbounds/update/${dbInboundId}`, dbInbound);
let inbound = dbInbound.toInbound();
const data = {
up: dbInbound.up,
down: dbInbound.down,
total: dbInbound.total,
remark: dbInbound.remark,
enable: dbInbound.enable,
expiryTime: dbInbound.expiryTime,
trafficReset: dbInbound.trafficReset,
lastTrafficResetTime: dbInbound.lastTrafficResetTime,
listen: inbound.listen,
port: inbound.port,
protocol: inbound.protocol,
settings: inbound.settings.toString(),
};
if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString();
} else if (inbound.stream?.sockopt) {
data.streamSettings = JSON.stringify({ sockopt: inbound.stream.sockopt.toJson() }, null, 2);
}
data.sniffing = inbound.sniffing.toString();
const formData = new FormData();
Object.keys(data).forEach(key => formData.append(key, data[key]));
this.submit(`/panel/api/inbounds/update/${dbInboundId}`, formData);
},
async switchEnableClient(dbInboundId, client) {
this.loading()
@@ -1367,42 +1394,54 @@
isExpiry(dbInbound, index) {
return dbInbound.toInbound().isExpiry(index);
},
getClientStats(dbInbound, email) {
if (!dbInbound) return null;
if (!dbInbound._clientStatsMap) {
dbInbound._clientStatsMap = new Map();
if (dbInbound.clientStats && Array.isArray(dbInbound.clientStats)) {
for (const stats of dbInbound.clientStats) {
dbInbound._clientStatsMap.set(stats.email, stats);
}
}
}
return dbInbound._clientStatsMap.get(email);
},
getUpStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
return clientStats ? clientStats.up : 0;
},
getDownStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
return clientStats ? clientStats.down : 0;
},
getSumStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
return clientStats ? clientStats.up + clientStats.down : 0;
},
getAllTimeClient(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return 0;
return clientStats.allTime || (clientStats.up + clientStats.down);
},
getRemStats(dbInbound, email) {
if (email.length == 0) return 0;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 0;
let clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return 0;
remained = clientStats.total - (clientStats.up + clientStats.down);
let remained = clientStats.total - (clientStats.up + clientStats.down);
return remained > 0 ? remained : 0;
},
clientStatsColor(dbInbound, email) {
if (email.length == 0) return ColorUtils.clientUsageColor();
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return ColorUtils.clientUsageColor();
let clientStats = this.getClientStats(dbInbound, email);
return ColorUtils.clientUsageColor(clientStats, app.trafficDiff)
},
statsProgress(dbInbound, email) {
if (email.length == 0) return 100;
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return 100;
let clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return 0;
if (clientStats.total == 0) return 100;
return 100 * (clientStats.down + clientStats.up) / clientStats.total;
@@ -1415,11 +1454,11 @@
return 100 * (1 - (remainedSeconds / resetSeconds));
},
statsExpColor(dbInbound, email) {
if (email.length == 0) return '#7a316f';
clientStats = dbInbound.clientStats.find(stats => stats.email === email);
if (!email || email.length == 0) return '#7a316f';
let clientStats = this.getClientStats(dbInbound, email);
if (!clientStats) return '#7a316f';
statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
let statsColor = ColorUtils.usageColor(clientStats.down + clientStats.up, this.trafficDiff, clientStats.total);
let expColor = ColorUtils.usageColor(new Date().getTime(), this.expireDiff, clientStats.expiryTime);
switch (true) {
case statsColor == "red" || expColor == "red":
return "#cf3c3c"; // Red
@@ -1432,12 +1471,12 @@
}
},
isClientEnabled(dbInbound, email) {
clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null;
let clientStats = dbInbound ? this.getClientStats(dbInbound, email) : null;
return clientStats ? clientStats['enable'] : true;
},
isClientDepleted(dbInbound, email) {
if (!email || !dbInbound || !dbInbound.clientStats) return false;
const stats = dbInbound.clientStats.find(s => s.email === email);
if (!email || !dbInbound) return false;
const stats = this.getClientStats(dbInbound, email);
if (!stats) return false;
const total = stats.total ?? 0;
const used = (stats.up ?? 0) + (stats.down ?? 0);
@@ -1557,12 +1596,18 @@
pagination(obj) {
if (this.pageSize > 0 && obj.length > this.pageSize) {
// Set page options based on object size
sizeOptions = [];
for (i = this.pageSize; i <= obj.length; i = i + this.pageSize) {
sizeOptions.push(i.toString());
let sizeOptions = [this.pageSize.toString()];
const increments = [2, 5, 10, 20];
for (const m of increments) {
const val = this.pageSize * m;
if (val < obj.length && val <= 1000) {
sizeOptions.push(val.toString());
}
}
// Add option to see all in one page
sizeOptions.push(i.toString());
if (!sizeOptions.includes(obj.length.toString())) {
sizeOptions.push(obj.length.toString());
}
p = {
showSizeChanger: true,
@@ -1605,11 +1650,25 @@
}
});
// Listen for invalidate signals (sent when payload is too large for WebSocket)
// The server sends a lightweight notification and we re-fetch via REST API
let invalidateTimer = null;
window.wsClient.on('invalidate', (payload) => {
if (payload && (payload.type === 'inbounds' || payload.type === 'traffic')) {
// Debounce to avoid flooding the REST API with multiple invalidate signals
if (invalidateTimer) clearTimeout(invalidateTimer);
invalidateTimer = setTimeout(() => {
invalidateTimer = null;
this.getDBInbounds();
}, 1000);
}
});
// Listen for traffic updates
window.wsClient.on('traffic', (payload) => {
// Note: Do NOT update total consumed traffic (stats.up, stats.down) from this event
// because clientTraffics contains delta/incremental values, not total accumulated values.
// Total traffic is updated via the 'inbounds' event which contains accumulated values from database.
// Total traffic is updated via the 'inbounds' WebSocket event (or 'invalidate' fallback for large panels).
// Update online clients list in real-time
if (payload && Array.isArray(payload.onlineClients)) {
@@ -1627,22 +1686,27 @@
this.onlineClients = nextOnlineClients;
if (onlineChanged) {
// Recalculate client counts to update online status
// Use $set for Vue 2 reactivity — direct array index assignment is not reactive
this.dbInbounds.forEach(dbInbound => {
const inbound = this.inbounds.find(ib => ib.id === dbInbound.id);
if (inbound && this.clientCount[dbInbound.id]) {
this.clientCount[dbInbound.id] = this.getClientCounts(dbInbound, inbound);
this.$set(this.clientCount, dbInbound.id, this.getClientCounts(dbInbound, inbound));
}
});
// Always trigger UI refresh — not just when filter is enabled
if (this.enableFilter) {
this.filterInbounds();
} else {
this.searchInbounds(this.searchKey);
}
}
}
// Update last online map in real-time
// Replace entirely (server sends the full map) to avoid unbounded growth from deleted clients
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
this.lastOnlineMap = { ...this.lastOnlineMap, ...payload.lastOnlineMap };
this.lastOnlineMap = payload.lastOnlineMap;
}
});
@@ -1697,4 +1761,18 @@
},
});
</script>
<style>
#content-layout > .ant-layout-content > .ant-spin-nested-loading > div > .ant-spin {
position: fixed !important;
top: 50vh !important;
left: calc(50vw + 100px) !important;
transform: translate(-50%, -50%);
z-index: 99999 !important;
}
@media (max-width: 768px) {
#content-layout > .ant-layout-content > .ant-spin-nested-loading > div > .ant-spin {
left: 50vw !important;
}
}
</style>
{{ template "page/body_end" .}}