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:
Egor
2026-10-05 22:46:46 +05:00
committed by GitHub
parent e897b0957a
commit aacfaebab8
14 changed files with 310 additions and 50 deletions
+60 -4
View File
@@ -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();
}
});
});