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:
+115
-37
@@ -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" .}}
|
||||
Reference in New Issue
Block a user