mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-06 22:22:08 +03:00
fix(tuic): client speed display and certificate button layout (#6723)
* fix(tuic): restore client speed and certificate layout * docs(tuic): clarify native runtime and protocol behavior * fix(websocket): preserve traffic updates from independent sources * fix(docs): sync websocket traffic schema and drop restating TUIC tests docs/public/openapi.json still described the old traffic event, without clientTrafficSource/clientTrafficIntervalMs or the TUIC oneOf branch, so the docs site showed a payload the panel no longer sends. No check covers that copy. The TUIC certificate layout test and the TUIC speed payload test only read back the literals the code writes, so neither could fail on a real regression. Both are removed, along with the className that existed only for the layout test. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -29,7 +29,7 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
|
||||
- **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel, and TUN.
|
||||
- **Modern transports & security** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade, and XHTTP, secured with TLS, XTLS, and REALITY.
|
||||
- **AmneziaWG built in** — DPI-resistant WireGuard runs inside the panel on a userspace network stack, with no kernel module, DKMS, or extra packages to install.
|
||||
- **TUIC v5 sidecar** — High-performance QUIC-based proxy with native UDP relay traffic metering, 0-RTT handshakes, and BBR congestion control.
|
||||
- **Native TUIC v5 server** — In-process Go QUIC server with Xray routing and per-client traffic accounting; BBR and New Reno are available server-side. CUBIC is preserved in the client profile but currently falls back to New Reno on the server.
|
||||
- **MTProto proxies** — per-client FakeTLS secrets, ad-tags, and quotas, applied live without dropping existing connections.
|
||||
- **Fallbacks** — serve multiple protocols on a single port (e.g. VLESS and Trojan on 443) using Xray's fallback support.
|
||||
- **Per-client management** — traffic quotas, expiry dates, IP limits with trusted-address exemptions, HWID device limits, scheduled renewal cycles, live online status, and one-click share links, QR codes, and subscriptions.
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
- **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN.
|
||||
- **Современные транспорты и безопасность** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade и XHTTP, защищённые с помощью TLS, XTLS и REALITY.
|
||||
- **Встроенный AmneziaWG** — устойчивый к DPI WireGuard работает прямо в панели на сетевом стеке в пространстве пользователя: без модуля ядра, DKMS и дополнительных пакетов.
|
||||
- **Встроенный TUIC v5** — высокопроизводительный прокси на базе QUIC с нативным учётом трафика через UDP-релей, 0-RTT рукопожатиями и контролем перегрузок BBR.
|
||||
- **Нативный TUIC v5** — Go QUIC-сервер работает внутри процесса панели. Трафик маршрутизируется через Xray, а учёт ведётся по клиентам. На сервере доступны BBR и New Reno; CUBIC сохраняется в профиле клиента, но на сервере пока использует New Reno.
|
||||
- **MTProto-прокси** — секреты FakeTLS, ad-tag и квоты для каждого клиента применяются на лету, не разрывая существующие соединения.
|
||||
- **Fallback** — обслуживание нескольких протоколов на одном порту (например, VLESS и Trojan на 443) с помощью функции fallback в Xray.
|
||||
- **Управление по каждому клиенту** — квоты трафика, даты истечения, лимиты IP с исключениями для доверенных адресов, лимиты устройств (HWID), запланированные циклы продления, статус «онлайн» в реальном времени, а также ссылки для общего доступа, QR-коды и подписки в один клик.
|
||||
|
||||
@@ -15,16 +15,16 @@ unstable networks.
|
||||
|
||||
## Key settings
|
||||
|
||||
### Server & QUIC parameters
|
||||
### Server, QUIC & client-profile parameters
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| **Port** | UDP port for incoming client QUIC connections. |
|
||||
| **Certificate & Key** | Full TLS certificate chain and private key. QUIC mandates TLS encryption; self-signed certificates or valid Let's Encrypt / ACME certs are supported. |
|
||||
| **SNI** | Server Name Indication matching your TLS certificate domain name. |
|
||||
| **Congestion Control** | QUIC congestion control algorithm: `bbr` (recommended for high throughput), `cubic`, or `new_reno`. The server runs `bbr` or `new_reno`; `cubic` is sent to clients but served as `new_reno`. |
|
||||
| **SNI** | Client-profile Server Name Indication. Set it to the domain covered by the server certificate; this field does not configure the listener certificate. |
|
||||
| **Congestion Control** | QUIC congestion control algorithm used in the server setting and exported client profile: `bbr`, `cubic`, or `new_reno`. The server runs BBR or New Reno; when CUBIC is selected, the client profile keeps CUBIC while this server currently falls back to New Reno. |
|
||||
| **ALPN** | Application-Layer Protocol Negotiation tokens (default: `h3`). |
|
||||
| **UDP Relay Mode** | Packet encapsulation mode: `native` (QUIC datagrams, recommended) or `quic`. |
|
||||
| **UDP Relay Mode** | Client-profile packet mode: `native` (QUIC datagrams) or `quic` (unidirectional streams). The server accepts both modes regardless of this exported preference. |
|
||||
| **Zero-RTT Handshake** | Enables 0-RTT connection resumption to eliminate initial handshake round-trips for returning clients. |
|
||||
| **Authentication Timeout** | Maximum time (seconds) allowed for client authentication before disconnecting (default: `3s`). |
|
||||
| **Max Idle Time** | Inactivity timeout (seconds) before closing idle QUIC connections (default: `15s`). |
|
||||
@@ -105,6 +105,8 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
|
||||
- **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain.
|
||||
- **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. Inbound tags (`in-<port>-udp`) work seamlessly with routing rules, domain/IP blocks, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.).
|
||||
- **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client.
|
||||
- **Live speed & traffic totals**: Native TUIC client counters are sampled every 10 seconds and sent to the panel for per-client live speed. Xray meters inbound totals through the loopback relay; TUIC's client-speed event does not add inbound totals again.
|
||||
- **UDP resource bounds**: Each QUIC connection can hold up to 256 active UDP associations. Idle associations are closed after five minutes. This is a per-connection limit, not a node-wide association cap. TUIC uses a reserved loopback SOCKS relay port in `64001–65000`; 3x-ui checks it against managed inbound and relay ports.
|
||||
- **Zero-downtime client updates**: Adding, modifying, or disabling clients updates the in-memory user registry instantly without restarting the UDP port or interrupting existing client sessions.
|
||||
- **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node.
|
||||
</Callout>
|
||||
|
||||
@@ -14,16 +14,16 @@ icon: Zap
|
||||
|
||||
## Ключевые параметры
|
||||
|
||||
### Параметры сервера и QUIC
|
||||
### Параметры сервера, QUIC и клиентского профиля
|
||||
|
||||
| Поле | Описание |
|
||||
| --- | --- |
|
||||
| **Порт** | UDP-порт для входящих QUIC-соединений клиентов. |
|
||||
| **Сертификат и ключ** | Полная цепочка SSL-сертификата и приватный ключ. Протокол QUIC требует обязательного шифрования TLS; поддерживаются сертификаты Let's Encrypt / ACME или самоподписанные. |
|
||||
| **SNI** | Имя сервера (Server Name Indication), совпадающее с доменным именем в сертификате. |
|
||||
| **Контроль перегрузок** | Алгоритм контроля перегрузок QUIC: `bbr` (рекомендуется для максимальной скорости), `cubic` или `new_reno`. Сервер работает с `bbr` или `new_reno`; `cubic` передаётся клиентам, но на сервере применяется как `new_reno`. |
|
||||
| **SNI** | Server Name Indication для профиля клиента. Укажите домен, покрытый сертификатом сервера; это поле не настраивает сертификат listener'а. |
|
||||
| **Контроль перегрузок** | Алгоритм QUIC в настройках сервера и экспортируемом профиле: `bbr`, `cubic` или `new_reno`. Сервер использует BBR или New Reno; при выборе CUBIC клиентский профиль сохраняет CUBIC, а сервер пока применяет New Reno. |
|
||||
| **ALPN** | Токены протоколов уровня приложений (по умолчанию: `h3`). |
|
||||
| **Режим UDP Relay** | Режим инкапсуляции пакетов: `native` (QUIC datagrams, рекомендуется) или `quic`. |
|
||||
| **Режим UDP Relay** | Режим UDP в профиле клиента: `native` (QUIC datagrams) или `quic` (однонаправленные потоки). Сервер принимает оба режима независимо от этого значения. |
|
||||
| **Zero-RTT Handshake** | Включает 0-RTT возобновление сессий для мгновенного повторного подключения клиентов без ожидания завершения рукопожатия. |
|
||||
| **Таймаут аутентификации** | Максимальное время (в секундах) на прохождение аутентификации клиентом (по умолчанию: `3s`). |
|
||||
| **Максимальный простой** | Таймаут бездействия (в секундах) перед закрытием неактивных QUIC-соединений (по умолчанию: `15s`). |
|
||||
@@ -104,6 +104,8 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
|
||||
- **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется.
|
||||
- **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Теги инбаундов (`in-<port>-udp`) полноценно участвуют в правилах маршрутизации (Routing Rules), блокировках geosite/geoip и перенаправлении в любые аутбаунды (VLESS, Shadowsocks, WARP и др.).
|
||||
- **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента.
|
||||
- **Скорость и общий трафик**: Нативные счётчики клиентов TUIC опрашиваются раз в 10 секунд и передаются в панель для отображения скорости. Xray отдельно считает общий трафик инбаунда через локальный relay; событие скорости TUIC повторно его не начисляет.
|
||||
- **Ограничения UDP**: На одно QUIC-соединение допускается до 256 активных UDP-ассоциаций. Неактивные ассоциации закрываются через пять минут. Это лимит на соединение, а не общий лимит узла. TUIC использует выделенный локальный SOCKS-порт из диапазона `64001–65000`; 3x-ui проверяет его конфликты с управляемыми инбаундами и relay-портами.
|
||||
- **Горячее обновление без обрыва связи**: Добавление, редактирование или отключение клиентов обновляет реестр пользователей в памяти без перезапуска порта и без сброса активных сессий других пользователей.
|
||||
- **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
|
||||
</Callout>
|
||||
|
||||
@@ -16027,15 +16027,9 @@
|
||||
},
|
||||
{
|
||||
"type": "traffic",
|
||||
"summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.",
|
||||
"summary": "Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.",
|
||||
"payloadSchema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"onlineClients",
|
||||
"onlineByGuid",
|
||||
"activeInbounds",
|
||||
"lastOnlineMap"
|
||||
],
|
||||
"properties": {
|
||||
"traffics": {
|
||||
"type": "array",
|
||||
@@ -16049,6 +16043,18 @@
|
||||
"$ref": "#/components/schemas/ClientTraffic"
|
||||
}
|
||||
},
|
||||
"clientTrafficSource": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"xray",
|
||||
"tuic"
|
||||
],
|
||||
"description": "Present for native TUIC samples; omitted Xray samples default to xray."
|
||||
},
|
||||
"clientTrafficIntervalMs": {
|
||||
"type": "integer",
|
||||
"description": "Sampling interval used to calculate client speed, in milliseconds."
|
||||
},
|
||||
"nodeTraffics": {
|
||||
"type": "array",
|
||||
"nullable": true,
|
||||
@@ -16092,13 +16098,24 @@
|
||||
{
|
||||
"required": [
|
||||
"traffics",
|
||||
"clientTraffics"
|
||||
"clientTraffics",
|
||||
"onlineClients",
|
||||
"onlineByGuid",
|
||||
"activeInbounds",
|
||||
"lastOnlineMap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"nodeTraffics"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"clientTraffics",
|
||||
"clientTrafficSource",
|
||||
"clientTrafficIntervalMs"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -16027,15 +16027,9 @@
|
||||
},
|
||||
{
|
||||
"type": "traffic",
|
||||
"summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.",
|
||||
"summary": "Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.",
|
||||
"payloadSchema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"onlineClients",
|
||||
"onlineByGuid",
|
||||
"activeInbounds",
|
||||
"lastOnlineMap"
|
||||
],
|
||||
"properties": {
|
||||
"traffics": {
|
||||
"type": "array",
|
||||
@@ -16049,6 +16043,18 @@
|
||||
"$ref": "#/components/schemas/ClientTraffic"
|
||||
}
|
||||
},
|
||||
"clientTrafficSource": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"xray",
|
||||
"tuic"
|
||||
],
|
||||
"description": "Present for native TUIC samples; omitted Xray samples default to xray."
|
||||
},
|
||||
"clientTrafficIntervalMs": {
|
||||
"type": "integer",
|
||||
"description": "Sampling interval used to calculate client speed, in milliseconds."
|
||||
},
|
||||
"nodeTraffics": {
|
||||
"type": "array",
|
||||
"nullable": true,
|
||||
@@ -16092,13 +16098,24 @@
|
||||
{
|
||||
"required": [
|
||||
"traffics",
|
||||
"clientTraffics"
|
||||
"clientTraffics",
|
||||
"onlineClients",
|
||||
"onlineByGuid",
|
||||
"activeInbounds",
|
||||
"lastOnlineMap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"nodeTraffics"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"clientTraffics",
|
||||
"clientTrafficSource",
|
||||
"clientTrafficIntervalMs"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -98,6 +98,8 @@ export interface ClientSpeedEntry {
|
||||
down: number;
|
||||
}
|
||||
|
||||
type ClientSpeedSource = 'xray' | 'tuic';
|
||||
|
||||
type ClientStatRow = ClientTraffic & { email?: string };
|
||||
|
||||
export function sameSpeedMap(
|
||||
@@ -299,7 +301,31 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
// settings request still lets the page fall back and render.
|
||||
const settingsReady = defaultsQuery.isFetched;
|
||||
|
||||
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
||||
const [clientSpeedBySource, setClientSpeedBySource] = useState<
|
||||
Partial<Record<ClientSpeedSource, Record<string, ClientSpeedEntry>>>
|
||||
>({});
|
||||
const clientSpeedExpiryTimers = useRef<Partial<Record<ClientSpeedSource, number>>>({});
|
||||
const clientSpeedSourceVersions = useRef<Record<ClientSpeedSource, number>>({ xray: 0, tuic: 0 });
|
||||
const clientSpeed = useMemo(() => {
|
||||
const combined: Record<string, ClientSpeedEntry> = {};
|
||||
for (const source of Object.values(clientSpeedBySource)) {
|
||||
if (!source) continue;
|
||||
for (const [email, speed] of Object.entries(source)) {
|
||||
const current = combined[email] ?? { up: 0, down: 0 };
|
||||
combined[email] = { up: current.up + speed.up, down: current.down + speed.down };
|
||||
}
|
||||
}
|
||||
return combined;
|
||||
}, [clientSpeedBySource]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const timer of Object.values(clientSpeedExpiryTimers.current)) {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
|
||||
|
||||
const invalidateAll = useCallback(() => {
|
||||
@@ -725,6 +751,8 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
const p = payload as {
|
||||
onlineClients?: string[];
|
||||
clientTraffics?: { email: string; up: number; down: number }[];
|
||||
clientTrafficSource?: 'xray' | 'tuic';
|
||||
clientTrafficIntervalMs?: number;
|
||||
};
|
||||
if (Array.isArray(p.onlineClients)) {
|
||||
queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
|
||||
@@ -736,17 +764,45 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
// dropped and an unchanged result returns the previous object — which lets
|
||||
// React bail out of the update instead of re-rendering the table.
|
||||
const next: Record<string, ClientSpeedEntry> = {};
|
||||
const source = p.clientTrafficSource === 'tuic' ? 'tuic' : 'xray';
|
||||
const sampleIntervalMs =
|
||||
typeof p.clientTrafficIntervalMs === 'number' &&
|
||||
Number.isFinite(p.clientTrafficIntervalMs) &&
|
||||
p.clientTrafficIntervalMs > 0
|
||||
? p.clientTrafficIntervalMs
|
||||
: TRAFFIC_POLL_INTERVAL_S * 1000;
|
||||
const sampleIntervalSeconds = sampleIntervalMs / 1000;
|
||||
for (const ct of p.clientTraffics) {
|
||||
if (!ct || !ct.email) continue;
|
||||
const up = ct.up || 0;
|
||||
const down = ct.down || 0;
|
||||
if (up === 0 && down === 0) continue;
|
||||
const current = next[ct.email] ?? { up: 0, down: 0 };
|
||||
next[ct.email] = {
|
||||
up: up / TRAFFIC_POLL_INTERVAL_S,
|
||||
down: down / TRAFFIC_POLL_INTERVAL_S,
|
||||
up: current.up + up / sampleIntervalSeconds,
|
||||
down: current.down + down / sampleIntervalSeconds,
|
||||
};
|
||||
}
|
||||
setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
|
||||
setClientSpeedBySource((prev) =>
|
||||
sameSpeedMap(prev[source] ?? {}, next) ? prev : { ...prev, [source]: next },
|
||||
);
|
||||
|
||||
const version = ++clientSpeedSourceVersions.current[source];
|
||||
const previousTimer = clientSpeedExpiryTimers.current[source];
|
||||
if (previousTimer !== undefined) window.clearTimeout(previousTimer);
|
||||
clientSpeedExpiryTimers.current[source] = window.setTimeout(
|
||||
() => {
|
||||
if (clientSpeedSourceVersions.current[source] !== version) return;
|
||||
delete clientSpeedExpiryTimers.current[source];
|
||||
setClientSpeedBySource((prev) => {
|
||||
if (!prev[source]) return prev;
|
||||
const nextSources = { ...prev };
|
||||
delete nextSources[source];
|
||||
return nextSources;
|
||||
});
|
||||
},
|
||||
Math.min(sampleIntervalMs * 2, 120_000),
|
||||
);
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
|
||||
@@ -120,10 +120,18 @@ const statusPayloadSchema = {
|
||||
|
||||
const trafficPayloadSchema = {
|
||||
type: 'object',
|
||||
required: ['onlineClients', 'onlineByGuid', 'activeInbounds', 'lastOnlineMap'],
|
||||
properties: {
|
||||
traffics: { type: 'array', items: { $ref: '#/components/schemas/Traffic' } },
|
||||
clientTraffics: { type: 'array', items: { $ref: '#/components/schemas/ClientTraffic' } },
|
||||
clientTrafficSource: {
|
||||
type: 'string',
|
||||
enum: ['xray', 'tuic'],
|
||||
description: 'Present for native TUIC samples; omitted Xray samples default to xray.',
|
||||
},
|
||||
clientTrafficIntervalMs: {
|
||||
type: 'integer',
|
||||
description: 'Sampling interval used to calculate client speed, in milliseconds.',
|
||||
},
|
||||
nodeTraffics: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
@@ -134,7 +142,20 @@ const trafficPayloadSchema = {
|
||||
activeInbounds: stringArrayMap,
|
||||
lastOnlineMap: timestampMap,
|
||||
},
|
||||
oneOf: [{ required: ['traffics', 'clientTraffics'] }, { required: ['nodeTraffics'] }],
|
||||
oneOf: [
|
||||
{
|
||||
required: [
|
||||
'traffics',
|
||||
'clientTraffics',
|
||||
'onlineClients',
|
||||
'onlineByGuid',
|
||||
'activeInbounds',
|
||||
'lastOnlineMap',
|
||||
],
|
||||
},
|
||||
{ required: ['nodeTraffics'] },
|
||||
{ required: ['clientTraffics', 'clientTrafficSource', 'clientTrafficIntervalMs'] },
|
||||
],
|
||||
};
|
||||
|
||||
const clientStatsPayloadSchema = {
|
||||
@@ -205,7 +226,7 @@ export function buildWebSocketEvents(
|
||||
{
|
||||
type: 'traffic',
|
||||
summary:
|
||||
'Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.',
|
||||
'Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.',
|
||||
payloadSchema: trafficPayloadSchema,
|
||||
example: {
|
||||
type: 'traffic',
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function TuicFields() {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label=" ">
|
||||
<Space>
|
||||
<Space wrap style={{ display: 'flex', flexWrap: 'wrap', width: '100%' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
|
||||
@@ -115,4 +115,71 @@ describe('client summary always reflects the server, never a client_stats recomp
|
||||
|
||||
expect(result.current.summary).toEqual(serverSummary);
|
||||
});
|
||||
|
||||
it('combines independently sampled TUIC and Xray speeds and replaces each source snapshot', async () => {
|
||||
const result = await loadedHook();
|
||||
|
||||
act(() => {
|
||||
result.current.applyTrafficEvent({
|
||||
clientTraffics: [{ email: 'shared@example.test', up: 100, down: 150 }],
|
||||
});
|
||||
});
|
||||
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 20, down: 30 });
|
||||
|
||||
act(() => {
|
||||
result.current.applyTrafficEvent({
|
||||
clientTrafficSource: 'tuic',
|
||||
clientTrafficIntervalMs: 10_000,
|
||||
clientTraffics: [
|
||||
{ email: 'shared@example.test', up: 300, down: 100 },
|
||||
{ email: 'shared@example.test', up: 100, down: 100 },
|
||||
],
|
||||
});
|
||||
});
|
||||
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 60, down: 50 });
|
||||
|
||||
act(() => {
|
||||
result.current.applyTrafficEvent({
|
||||
clientTraffics: [{ email: 'shared@example.test', up: 50, down: 25 }],
|
||||
});
|
||||
});
|
||||
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 50, down: 25 });
|
||||
|
||||
act(() => {
|
||||
result.current.applyTrafficEvent({
|
||||
clientTrafficSource: 'tuic',
|
||||
clientTrafficIntervalMs: 10_000,
|
||||
clientTraffics: [],
|
||||
});
|
||||
});
|
||||
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 10, down: 5 });
|
||||
});
|
||||
|
||||
it('expires stale per-source speeds without clearing the other source', async () => {
|
||||
const result = await loadedHook();
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
act(() => {
|
||||
result.current.applyTrafficEvent({
|
||||
clientTrafficSource: 'xray',
|
||||
clientTrafficIntervalMs: 1_000,
|
||||
clientTraffics: [{ email: 'shared@example.test', up: 100, down: 200 }],
|
||||
});
|
||||
result.current.applyTrafficEvent({
|
||||
clientTrafficSource: 'tuic',
|
||||
clientTrafficIntervalMs: 2_000,
|
||||
clientTraffics: [{ email: 'shared@example.test', up: 300, down: 400 }],
|
||||
});
|
||||
});
|
||||
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 250, down: 400 });
|
||||
|
||||
act(() => vi.advanceTimersByTime(2_000));
|
||||
expect(result.current.clientSpeed['shared@example.test']).toEqual({ up: 150, down: 200 });
|
||||
|
||||
act(() => vi.advanceTimersByTime(2_000));
|
||||
expect(result.current.clientSpeed).toEqual({});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,22 @@ package job
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
const defaultTuicSpeedSampleInterval = 10 * time.Second
|
||||
|
||||
type TuicJob struct {
|
||||
inboundService service.InboundService
|
||||
inboundService service.InboundService
|
||||
runMu sync.Mutex
|
||||
lastSpeedSample time.Time
|
||||
}
|
||||
|
||||
func NewTuicJob() *TuicJob {
|
||||
@@ -19,6 +25,9 @@ func NewTuicJob() *TuicJob {
|
||||
}
|
||||
|
||||
func (j *TuicJob) Run() {
|
||||
j.runMu.Lock()
|
||||
defer j.runMu.Unlock()
|
||||
|
||||
tuicJournalMu.Lock()
|
||||
journalErr := j.replayTuicJournal()
|
||||
tuicJournalMu.Unlock()
|
||||
@@ -44,20 +53,28 @@ func (j *TuicJob) Run() {
|
||||
onlineEmails, _ := mgr.GetActiveClients(30 * time.Second)
|
||||
|
||||
clientTraffics := aggregateTuicClientTraffic(clientDeltas, onlineEmails)
|
||||
sampledAt := time.Now()
|
||||
sampleInterval := tuicSpeedSampleInterval(j.lastSpeedSample, sampledAt)
|
||||
|
||||
// Inbound total traffic is already metered through the loopback SOCKS relay
|
||||
// by xray_traffic_job (matching mtproto); only per-client deltas are submitted here.
|
||||
persisted := true
|
||||
if len(clientTraffics) > 0 {
|
||||
needRestart, _, err := j.inboundService.AddTraffic(nil, clientTraffics)
|
||||
if err != nil {
|
||||
logger.Warning("tuic job: add traffic failed:", err)
|
||||
mgr.RequeueClientTraffic(clientDeltas)
|
||||
persisted = false
|
||||
} else if needRestart {
|
||||
if desired, err := j.inboundService.DesiredTuicInstances(); err == nil {
|
||||
mgr.Reconcile(desired)
|
||||
}
|
||||
}
|
||||
}
|
||||
if persisted {
|
||||
websocket.BroadcastTraffic(tuicSpeedPayload(clientTraffics, sampleInterval))
|
||||
j.lastSpeedSample = sampledAt
|
||||
}
|
||||
|
||||
if len(onlineEmails) > 0 {
|
||||
if err := j.inboundService.BumpClientsLastOnline(onlineEmails); err != nil {
|
||||
@@ -68,6 +85,25 @@ func (j *TuicJob) Run() {
|
||||
j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
|
||||
}
|
||||
|
||||
func tuicSpeedSampleInterval(previous, current time.Time) time.Duration {
|
||||
if previous.IsZero() || !current.After(previous) {
|
||||
return defaultTuicSpeedSampleInterval
|
||||
}
|
||||
return current.Sub(previous)
|
||||
}
|
||||
|
||||
func tuicSpeedPayload(clientTraffics []*xray.ClientTraffic, sampleInterval time.Duration) map[string]any {
|
||||
intervalMs := sampleInterval.Milliseconds()
|
||||
if intervalMs < 1 {
|
||||
intervalMs = 1
|
||||
}
|
||||
return map[string]any{
|
||||
"clientTraffics": clientTraffics,
|
||||
"clientTrafficSource": "tuic",
|
||||
"clientTrafficIntervalMs": intervalMs,
|
||||
}
|
||||
}
|
||||
|
||||
// FlushStoppedTraffic persists counters drained when the TUIC manager stops its
|
||||
// listeners. Call it after scheduled jobs have stopped and before the traffic
|
||||
// writer shuts down.
|
||||
|
||||
@@ -180,3 +180,14 @@ func TestAggregateTuicClientTrafficPreservesStableIdentityAcrossEmailRename(t *t
|
||||
t.Fatalf("aggregate counters = (%d,%d), want (40,60)", got[0].Up, got[0].Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuicSpeedSampleIntervalUsesElapsedPollTime(t *testing.T) {
|
||||
current := time.Date(2026, time.October, 3, 12, 0, 10, 0, time.UTC)
|
||||
previous := current.Add(-12 * time.Second)
|
||||
if got := tuicSpeedSampleInterval(previous, current); got != 12*time.Second {
|
||||
t.Fatalf("sample interval = %s, want 12s", got)
|
||||
}
|
||||
if got := tuicSpeedSampleInterval(time.Time{}, current); got != defaultTuicSpeedSampleInterval {
|
||||
t.Fatalf("initial sample interval = %s, want %s", got, defaultTuicSpeedSampleInterval)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,10 +94,12 @@ func NewHub() *Hub {
|
||||
}
|
||||
}
|
||||
|
||||
// Traffic messages carry independent partial updates from Xray, TUIC, and node
|
||||
// polling jobs. Throttling by message type would silently discard one source
|
||||
// when two jobs publish within the throttle window.
|
||||
var throttledMessageTypes = map[MessageType]struct{}{
|
||||
MessageTypeInbounds: {},
|
||||
MessageTypeOutbounds: {},
|
||||
MessageTypeTraffic: {},
|
||||
MessageTypeClientStats: {},
|
||||
}
|
||||
|
||||
|
||||
@@ -81,21 +81,45 @@ func TestHub_BroadcastDeliversToClient(t *testing.T) {
|
||||
waitClientCount(t, h, 1)
|
||||
|
||||
h.Broadcast(MessageTypeStatus, map[string]string{"k": "v"})
|
||||
|
||||
select {
|
||||
case raw := <-c.Send:
|
||||
var m Message
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("payload is not valid JSON: %v\n%s", err, raw)
|
||||
var message Message
|
||||
if err := json.Unmarshal(raw, &message); err != nil {
|
||||
t.Fatalf("payload is not valid JSON: %v", err)
|
||||
}
|
||||
if m.Type != MessageTypeStatus {
|
||||
t.Fatalf("Type = %q, want %q", m.Type, MessageTypeStatus)
|
||||
if message.Type != MessageTypeStatus {
|
||||
t.Fatalf("message type = %q, want %q", message.Type, MessageTypeStatus)
|
||||
}
|
||||
if m.Time == 0 {
|
||||
if message.Time == 0 {
|
||||
t.Fatal("Time should be set to a non-zero unix-millis value")
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for broadcast to reach client")
|
||||
t.Fatal("timed out waiting for status broadcast to reach client")
|
||||
}
|
||||
|
||||
for _, source := range []string{"tuic", "xray"} {
|
||||
h.Broadcast(MessageTypeTraffic, map[string]string{"source": source})
|
||||
}
|
||||
|
||||
for _, wantSource := range []string{"tuic", "xray"} {
|
||||
select {
|
||||
case raw := <-c.Send:
|
||||
var message struct {
|
||||
Type MessageType `json:"type"`
|
||||
Payload map[string]string `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &message); err != nil {
|
||||
t.Fatalf("traffic event is not valid JSON: %v", err)
|
||||
}
|
||||
if message.Type != MessageTypeTraffic {
|
||||
t.Fatalf("message type = %q, want %q", message.Type, MessageTypeTraffic)
|
||||
}
|
||||
if got := message.Payload["source"]; got != wantSource {
|
||||
t.Fatalf("traffic source = %q, want %q", got, wantSource)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatalf("timed out waiting for %q traffic event", wantSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,23 +180,28 @@ func TestHub_ShouldThrottle(t *testing.T) {
|
||||
t.Fatal("non-gated message type should never throttle on second call")
|
||||
}
|
||||
|
||||
if h.shouldThrottle(MessageTypeTraffic) {
|
||||
if h.shouldThrottle(MessageTypeInbounds) {
|
||||
t.Fatal("first call for gated type should not throttle")
|
||||
}
|
||||
if !h.shouldThrottle(MessageTypeTraffic) {
|
||||
if !h.shouldThrottle(MessageTypeInbounds) {
|
||||
t.Fatal("immediate second call for gated type should throttle")
|
||||
}
|
||||
for i := range 2 {
|
||||
if h.shouldThrottle(MessageTypeTraffic) {
|
||||
t.Fatalf("traffic event %d must not be throttled", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ShouldThrottle_DistinctTypesIndependent(t *testing.T) {
|
||||
h := NewHub()
|
||||
defer h.Stop()
|
||||
|
||||
if h.shouldThrottle(MessageTypeTraffic) {
|
||||
t.Fatal("first Traffic call should not throttle")
|
||||
}
|
||||
if h.shouldThrottle(MessageTypeInbounds) {
|
||||
t.Fatal("first Inbounds call should not throttle even after Traffic")
|
||||
t.Fatal("first Inbounds call should not throttle")
|
||||
}
|
||||
if h.shouldThrottle(MessageTypeOutbounds) {
|
||||
t.Fatal("first Outbounds call should not throttle even after Inbounds")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user