Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/MHSanaei/3x-ui.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/web/html
diff options
context:
space:
mode:
authorlolka1333 <xtrafcyz@gmail.com>2026-04-19 22:01:00 +0300
committerGitHub <noreply@github.com>2026-04-19 22:01:00 +0300
commitfec714a2431c482024a0952982fa36f38935e7ed (patch)
treecb2e206b375a725623a0c0b18a22785d44037d67 /web/html
parente02f78ac68e96066288c5da0c38e293160b23143 (diff)
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
Diffstat (limited to 'web/html')
-rw-r--r--web/html/form/stream/stream_xhttp.html3
-rw-r--r--web/html/inbounds.html152
-rw-r--r--web/html/index.html6
-rw-r--r--web/html/modals/client_bulk_modal.html6
-rw-r--r--web/html/modals/client_modal.html8
-rw-r--r--web/html/settings.html7
-rw-r--r--web/html/xray.html15
7 files changed, 139 insertions, 58 deletions
diff --git a/web/html/form/stream/stream_xhttp.html b/web/html/form/stream/stream_xhttp.html
index 447612c9..8fe836d0 100644
--- a/web/html/form/stream/stream_xhttp.html
+++ b/web/html/form/stream/stream_xhttp.html
@@ -70,6 +70,8 @@
<a-select-option
value="queryInHeader">queryInHeader</a-select-option>
<a-select-option value="header">header</a-select-option>
+ <a-select-option value="cookie">cookie</a-select-option>
+ <a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Padding Method">
@@ -127,6 +129,7 @@
<a-select-option value>Default (body)</a-select-option>
<a-select-option value="body">body</a-select-option>
<a-select-option value="header">header</a-select-option>
+ <a-select-option value="cookie">cookie</a-select-option>
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
diff --git a/web/html/inbounds.html b/web/html/inbounds.html
index b945da90..231fc0c0 100644
--- a/web/html/inbounds.html
+++ b/web/html/inbounds.html
@@ -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" .}} \ No newline at end of file
diff --git a/web/html/index.html b/web/html/index.html
index bbbbb708..47645f7d 100644
--- a/web/html/index.html
+++ b/web/html/index.html
@@ -6,7 +6,7 @@
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
- <a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip">
+ <a-spin :spinning="loadingStates.spinning" :delay="200" :tip="loadingTip" size="large">
<transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched" class="mb-10"
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
@@ -15,9 +15,7 @@
<transition name="list" appear>
<template>
<a-row v-if="!loadingStates.fetched">
- <a-card class="card-placeholder text-center">
- <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>
diff --git a/web/html/modals/client_bulk_modal.html b/web/html/modals/client_bulk_modal.html
index ac0fa011..6e61feae 100644
--- a/web/html/modals/client_bulk_modal.html
+++ b/web/html/modals/client_bulk_modal.html
@@ -26,7 +26,7 @@
<a-input v-model.trim="clientsBulkModal.emailPostfix"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.client.clientCount" }}' v-if="clientsBulkModal.emailMethod < 2">
- <a-input-number v-model.number="clientsBulkModal.quantity" :min="1" :max="100"></a-input-number>
+ <a-input-number v-model.number="clientsBulkModal.quantity" :min="1" :max="500"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "security" }}' v-if="inbound.protocol === Protocols.VMESS">
<a-select v-model="clientsBulkModal.security" :dropdown-class-name="themeSwitcher.currentTheme">
@@ -204,7 +204,7 @@
this.security = "auto";
this.flow = "";
this.dbInbound = new DBInbound(dbInbound);
- this.inbound = dbInbound.toInbound();
+ this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
this.delayedStart = false;
this.reset = 0;
},
@@ -247,4 +247,4 @@
});
</script>
-{{end}} \ No newline at end of file
+{{end}}
diff --git a/web/html/modals/client_modal.html b/web/html/modals/client_modal.html
index 8b57b8b2..a5d3e7ac 100644
--- a/web/html/modals/client_modal.html
+++ b/web/html/modals/client_modal.html
@@ -37,7 +37,7 @@
this.okText = okText;
this.isEdit = isEdit;
this.dbInbound = new DBInbound(dbInbound);
- this.inbound = dbInbound.toInbound();
+ this.inbound = Inbound.fromJson(dbInbound.toInbound().toJson());
this.clients = this.inbound.clients;
this.index = index === null ? this.clients.length : index;
this.delayedStart = false;
@@ -98,9 +98,9 @@
return app.datepicker;
},
get isTrafficExhausted() {
- if (!clientStats) return false
- if (clientStats.total <= 0) return false
- if (clientStats.up + clientStats.down < clientStats.total) return false
+ if (!this.clientStats) return false
+ if (this.clientStats.total <= 0) return false
+ if (this.clientStats.up + this.clientStats.down < this.clientStats.total) return false
return true
},
get isExpiry() {
diff --git a/web/html/settings.html b/web/html/settings.html
index 21294da7..48aad524 100644
--- a/web/html/settings.html
+++ b/web/html/settings.html
@@ -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="confAlerts.length>0 && loadingStates.fetched" :style="{ marginBottom: '10px' }"
message='{{ i18n "secAlertTitle" }}' color="red" show-icon closable>
@@ -21,10 +21,7 @@
<transition name="list" appear>
<template>
<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>
diff --git a/web/html/xray.html b/web/html/xray.html
index ebe31f48..02243277 100644
--- a/web/html/xray.html
+++ b/web/html/xray.html
@@ -14,7 +14,7 @@
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="500"
- tip='{{ i18n "loading"}}'>
+ tip='{{ i18n "loading"}}' size="large">
<transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched"
:style="{ marginBottom: '10px' }"
@@ -24,10 +24,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>
@@ -1075,6 +1072,14 @@
this.$forceUpdate();
}
});
+
+ // Handle invalidate signals (sent when payload is too large for WebSocket,
+ // or when traffic job notifies about data changes)
+ window.wsClient.on('invalidate', (payload) => {
+ if (payload && payload.type === 'outbounds') {
+ this.refreshOutboundTraffic();
+ }
+ });
}
while (true) {