3x-ui/frontend/src/pages/xray/useXraySetting.js
MHSanaei 8834e5fbbe
feat(xray/outbounds): TCP probe mode + Test All + timing breakdown
- service.TestOutbound now dispatches on `mode`:
  - "tcp": parallel net.DialTimeout to every server/peer endpoint
    (vmess/vless/trojan/ss/socks/http/wireguard). No xray spin-up,
    no semaphore — safe to run concurrently across outbounds.
  - "http" (default): existing temp-xray + SOCKS path, now with an
    httptrace.ClientTrace breakdown (DNS / Connect / TLS / TTFB)
    alongside the total delay and status code.
- testSemaphore renamed to httpTestSemaphore — only HTTP probes
  serialise, TCP runs free.
- TestOutboundResult carries the per-mode extras: timing fields for
  HTTP, per-endpoint dial list for TCP, plus a `mode` echo.
- Controller reads `mode` from the form and passes it through.
- useXraySetting: testOutbound accepts mode (default "tcp"); new
  testAllOutbounds(mode) runs a worker pool (concurrency 8 for TCP,
  1 for HTTP) and skips blackhole / loopback / blocked outbounds —
  also skips freedom / dns under TCP since they have no endpoint.
- OutboundsTab: TCP/HTTP radio toggle and a Test All button land in
  the toolbar; the per-row  now uses the selected mode. Results
  surface in a popover with the full timing breakdown plus the
  endpoint list for TCP probes. Latency header replaces the duplicate
  "check" column title.

Practical effect: testing ten outbounds in TCP mode drops from ~50–100s
(serial HTTP) to ~1–2s (parallel dial × 8). HTTP mode stays as the
authoritative probe and now shows where the latency actually lives.
2026-05-11 04:17:23 +02:00

245 lines
6.9 KiB
JavaScript

import { onMounted, onUnmounted, ref, watch } from 'vue';
import { HttpUtil, PromiseUtil } from '@/utils';
const DIRTY_POLL_MS = 1000;
let syncing = false;
export function useXraySetting() {
const fetched = ref(false);
const spinning = ref(false);
const saveDisabled = ref(true);
const fetchError = ref('');
const xraySetting = ref('');
const oldXraySetting = ref('');
const templateSettings = ref(null);
const outboundTestUrl = ref('https://www.google.com/generate_204');
const oldOutboundTestUrl = ref('');
const inboundTags = ref([]);
const clientReverseTags = ref([]);
const restartResult = ref('');
const outboundsTraffic = ref([]);
const outboundTestStates = ref({});
async function fetchAll() {
fetchError.value = '';
const msg = await HttpUtil.post('/panel/xray/');
if (!msg?.success) {
fetchError.value = msg?.msg || 'Failed to load xray config';
fetched.value = true;
return;
}
let obj;
try {
obj = JSON.parse(msg.obj);
} catch (e) {
fetchError.value = `Malformed xray config response: ${e?.message || e}`;
fetched.value = true;
return;
}
const pretty = JSON.stringify(obj.xraySetting, null, 2);
syncing = true;
xraySetting.value = pretty;
oldXraySetting.value = pretty;
templateSettings.value = obj.xraySetting;
syncing = false;
inboundTags.value = obj.inboundTags || [];
clientReverseTags.value = obj.clientReverseTags || [];
outboundTestUrl.value = obj.outboundTestUrl || 'https://www.google.com/generate_204';
oldOutboundTestUrl.value = outboundTestUrl.value;
fetched.value = true;
saveDisabled.value = true;
}
watch(
templateSettings,
(next) => {
if (syncing || !next) return;
syncing = true;
try {
xraySetting.value = JSON.stringify(next, null, 2);
} finally {
syncing = false;
}
},
{ deep: true },
);
watch(xraySetting, (next) => {
if (syncing) return;
try {
const parsed = JSON.parse(next);
syncing = true;
try {
templateSettings.value = parsed;
} finally {
syncing = false;
}
} catch (_e) { /* ignore — wait for user to finish */ }
});
async function saveAll() {
spinning.value = true;
try {
const msg = await HttpUtil.post('/panel/xray/update', {
xraySetting: xraySetting.value,
outboundTestUrl: outboundTestUrl.value || 'https://www.google.com/generate_204',
});
if (msg?.success) await fetchAll();
} finally {
spinning.value = false;
}
}
async function fetchOutboundsTraffic() {
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic');
if (msg?.success) outboundsTraffic.value = msg.obj || [];
}
async function resetOutboundsTraffic(tag) {
const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag });
if (msg?.success) await fetchOutboundsTraffic();
}
function applyOutboundsEvent(payload) {
if (Array.isArray(payload)) outboundsTraffic.value = payload;
}
async function testOutbound(index, outbound, mode = 'tcp') {
if (!outbound) return null;
if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {};
outboundTestStates.value[index] = { testing: true, result: null, mode };
try {
const msg = await HttpUtil.post('/panel/xray/testOutbound', {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettings.value?.outbounds || []),
mode,
});
if (msg?.success) {
outboundTestStates.value[index] = { testing: false, result: msg.obj };
return msg.obj;
}
outboundTestStates.value[index] = {
testing: false,
result: { success: false, error: msg?.msg || 'Unknown error', mode },
};
} catch (e) {
outboundTestStates.value[index] = {
testing: false,
result: { success: false, error: String(e), mode },
};
}
return null;
}
const testingAll = ref(false);
async function testAllOutbounds(mode = 'tcp') {
const list = templateSettings.value?.outbounds || [];
if (list.length === 0 || testingAll.value) return;
testingAll.value = true;
try {
const concurrency = mode === 'tcp' ? 8 : 1;
const queue = list
.map((ob, i) => ({ index: i, outbound: ob }))
.filter(({ outbound }) => {
const tag = outbound?.tag;
const proto = outbound?.protocol;
if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
return true;
});
async function worker() {
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
await testOutbound(item.index, item.outbound, mode);
}
}
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, () => worker());
await Promise.all(workers);
} finally {
testingAll.value = false;
}
}
async function resetToDefault() {
spinning.value = true;
try {
const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
if (msg?.success) {
templateSettings.value = JSON.parse(JSON.stringify(msg.obj));
}
} finally {
spinning.value = false;
}
}
async function restartXray() {
spinning.value = true;
try {
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
if (msg?.success) {
// Match legacy: short pause, then poll for the result blob so
// the popover surfaces any startup error from the new process.
await PromiseUtil.sleep(500);
const r = await HttpUtil.get('/panel/xray/getXrayResult');
if (r?.success) restartResult.value = r.obj || '';
}
} finally {
spinning.value = false;
}
}
// Same 1s busy-loop pattern the settings page uses — keep it cheap
// and consistent. Real work (the JSON diff) is just a string compare.
let timer = null;
function startDirtyPoll() {
if (timer != null) return;
timer = setInterval(() => {
saveDisabled.value =
oldXraySetting.value === xraySetting.value
&& oldOutboundTestUrl.value === outboundTestUrl.value;
}, DIRTY_POLL_MS);
}
function stopDirtyPoll() {
if (timer != null) {
clearInterval(timer);
timer = null;
}
}
onMounted(() => {
fetchAll();
fetchOutboundsTraffic();
startDirtyPoll();
});
onUnmounted(stopDirtyPoll);
return {
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
templateSettings,
outboundTestUrl,
inboundTags,
clientReverseTags,
restartResult,
outboundsTraffic,
outboundTestStates,
testingAll,
fetchAll,
fetchOutboundsTraffic,
resetOutboundsTraffic,
applyOutboundsEvent,
testOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
restartXray,
};
}