P9(4): remove local-core live-test trio — outbound speed test + egress probe, restart/stop xray endpoints

Per user decision 2026-10-21 (reverses the earlier keep call in
PANEL-LITE-SPEC.md, now amended): the lite panel never runs a local
core, so measuring/probing from the panel host is meaningless.

Backend:
- delete service/outbound probe implementation (probe_http.go and its
  tests) and the TestOutbound/TestOutbounds batch prober; outbound
  traffic accounting (AddTraffic/GetOutboundsTraffic/Reset) unchanged
- drop POST /panel/api/xray/testOutbound + testOutbounds routes and the
  outboundTestUrl setting (get/update plumbing removed; stored rows in
  existing DBs are inert)
- drop POST /panel/api/server/restartXrayService + stopXrayService
  routes/handlers and the node-sync allowlist entry; the service-level
  Stop/RestartXrayService stay for internal import/restore flows
- node-sync allowlist contract test updated to the new inventory

Frontend:
- remove test buttons/mode switch/test-all from outbounds tab, card
  list and table columns; delete TestResultPopover and CountryPill;
  drop the egress/country/latency columns fed by probe results
- remove outboundTestUrl state and save plumbing from useXraySetting
  and the Basics tab field; drop OutboundTestResult schemas
- delete the 15 now-dead i18n keys from all 13 locales (incl. the
  restart/stop keys orphaned since P6/P7 — i18n-dead-keys test green)
- tests: drop the test-URL hook test, re-probe the loopback index
  mapping through Move-to-top, regenerate API types (gen)
This commit is contained in:
Muse
2026-10-21 16:35:53 +00:00
parent 6772c97f8e
commit 5031a70ccb
43 changed files with 112 additions and 3982 deletions
+5 -281
View File
@@ -7,46 +7,15 @@ import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import {
OutboundTrafficListSchema,
OutboundTestResultListSchema,
XrayConfigPayloadSchema,
XraySettingsValueSchema,
type OutboundTestResult,
type OutboundTrafficRow,
} from '@/schemas/xray';
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
// One HTTP-mode batch request tests this many outbounds through a single
// shared temp xray instance; chunking keeps responses bounded (~30s worst
// case — each probe is a cold plus a warm request) and lands Test All
// results progressively.
const HTTP_BATCH_CHUNK = 16;
function normalizeOutboundTestUrl(url: string) {
return url || DEFAULT_TEST_URL;
}
export function isUdpOutbound(outbound: unknown): boolean {
const o = outbound as
| { protocol?: string; streamSettings?: { network?: string } }
| null
| undefined;
const p = o?.protocol;
const n = o?.streamSettings?.network;
return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
}
export type OutboundTestMode = 'tcp' | 'http' | 'real';
export type { OutboundTrafficRow, OutboundTestResult };
export type { OutboundTrafficRow };
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export interface OutboundTestState {
testing?: boolean;
result?: OutboundTestResult | null;
mode?: string;
}
export type SetTemplate = (
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
) => void;
@@ -60,30 +29,14 @@ export interface UseXraySettingResult {
setXraySetting: (next: string) => void;
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundTestUrl: string;
setOutboundTestUrl: (v: string) => void;
inboundTags: string[];
clientReverseTags: string[];
subscriptionOutbounds: unknown[];
subscriptionOutboundTags: string[];
outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
subscriptionTestStates: Record<string, OutboundTestState>;
testingAll: boolean;
fetchAll: () => Promise<void>;
fetchOutboundsTraffic: () => Promise<void>;
resetOutboundsTraffic: (tag: string) => Promise<void>;
testOutbound: (
index: number,
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testSubscriptionOutbound: (
tag: string,
outbound: unknown,
mode?: string,
) => Promise<OutboundTestResult | null>;
testAllOutbounds: (mode?: string) => Promise<void>;
saveAll: () => Promise<void>;
resetToDefault: () => Promise<void>;
}
@@ -136,9 +89,7 @@ export function useXraySetting(): UseXraySettingResult {
const [xraySetting, setXraySettingState] = useState('');
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [savedXraySetting, setSavedXraySetting] = useState('');
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
const config = configQuery.data;
const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
@@ -147,21 +98,10 @@ export function useXraySetting(): UseXraySettingResult {
[config],
);
const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [config]);
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>(
{},
);
// Subscription outbounds aren't in templateSettings.outbounds, so their test
// results are keyed by tag rather than by index.
const [subscriptionTestStates, setSubscriptionTestStates] = useState<
Record<string, OutboundTestState>
>({});
const [testingAll, setTestingAll] = useState(false);
const syncingRef = useRef(false);
const xraySettingRef = useRef('');
const outboundTestUrlRef = useRef(outboundTestUrl);
const savedXraySettingRef = useRef(savedXraySetting);
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]);
@@ -169,9 +109,7 @@ export function useXraySetting(): UseXraySettingResult {
useEffect(() => {
xraySettingRef.current = xraySetting;
outboundTestUrlRef.current = outboundTestUrl;
savedXraySettingRef.current = savedXraySetting;
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
templateSettingsRef.current = templateSettings;
subscriptionOutboundsRef.current = subscriptionOutbounds;
});
@@ -180,17 +118,12 @@ export function useXraySetting(): UseXraySettingResult {
// of the previous config after a refetch. Local edits win over the refetch.
if (config && config !== syncedConfig) {
setSyncedConfig(config);
const isDirty =
savedXraySetting !== xraySetting ||
savedOutboundTestUrl !== normalizeOutboundTestUrl(outboundTestUrl);
const isDirty = savedXraySetting !== xraySetting;
if (!isDirty) {
const pretty = JSON.stringify(config.xraySetting, null, 2);
const nextUrl = normalizeOutboundTestUrl(config.outboundTestUrl || '');
setXraySettingState(pretty);
setTemplateSettingsState(config.xraySetting);
setSavedXraySetting(pretty);
setOutboundTestUrlState(nextUrl);
setSavedOutboundTestUrl(nextUrl);
}
}
@@ -226,10 +159,6 @@ export function useXraySetting(): UseXraySettingResult {
});
}, []);
const setOutboundTestUrl = useCallback((v: string) => {
setOutboundTestUrlState(v);
}, []);
const fetchAll = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
}, [queryClient]);
@@ -241,17 +170,14 @@ export function useXraySetting(): UseXraySettingResult {
const saveMut = useMutation({
mutationFn: async () => {
const sentXraySetting = xraySettingRef.current;
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: sentXraySetting,
outboundTestUrl: sentTestUrl,
});
return { msg, sentXraySetting, sentTestUrl };
return { msg, sentXraySetting };
},
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
onSuccess: ({ msg, sentXraySetting }) => {
if (!msg?.success) return;
setSavedXraySetting(sentXraySetting);
setSavedOutboundTestUrl(sentTestUrl);
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
},
});
@@ -291,193 +217,7 @@ export function useXraySetting(): UseXraySettingResult {
const spinning = saveMut.isPending || resetDefaultMut.isPending;
// Shared POST + parse for a batch of outbound tests. The backend probes the
// whole batch through one shared temp xray instance and returns results in
// request order; this aligns them by index and shapes failures so every
// input gets an OutboundTestResult.
const postOutboundTestBatch = useCallback(
async (outbounds: unknown[], effMode: string): Promise<OutboundTestResult[]> => {
const failAll = (error: string): OutboundTestResult[] =>
outbounds.map(() => ({ success: false, error, mode: effMode }));
try {
const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', {
outbounds: JSON.stringify(outbounds),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode: effMode,
});
const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
const list = msg.obj;
return outbounds.map(
(_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode },
);
} catch (e) {
return failAll(String(e));
}
},
[],
);
const testOutbound = useCallback(
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound) return null;
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
setOutboundTestStates((prev) => ({
...prev,
[index]: { testing: true, result: null, mode: effMode },
}));
const [result] = await postOutboundTestBatch([outbound], effMode);
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
return result.success ? result : null;
},
[postOutboundTestBatch],
);
// Test a subscription outbound (not present in templateSettings.outbounds);
// results are keyed by tag in subscriptionTestStates.
const testSubscriptionOutbound = useCallback(
async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
if (!outbound || !tag) return null;
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
setSubscriptionTestStates((prev) => ({
...prev,
[tag]: { testing: true, result: null, mode: effMode },
}));
const [result] = await postOutboundTestBatch([outbound], effMode);
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
return result.success ? result : null;
},
[postOutboundTestBatch],
);
const testAllOutbounds = useCallback(
async (mode = 'tcp') => {
// Template outbounds key their results by index (outboundTestStates);
// subscription outbounds aren't in the template, so they key by tag
// (subscriptionTestStates). Both go through the same probe endpoint.
const templateList = templateSettingsRef.current?.outbounds || [];
const subList = (subscriptionOutboundsRef.current || []) as Array<{
tag?: string;
protocol?: string;
}>;
if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
setTestingAll(true);
try {
type TcpEntry =
| { kind: 'tpl'; index: number; outbound: unknown }
| { kind: 'sub'; tag: string; outbound: unknown };
const tcpQueue: TcpEntry[] = [];
// HTTP batches stay homogeneous (all template or all subscription) so a
// tag shared between a template and a subscription outbound can't collide
// inside one batch, and each batch's results route to one state map.
const probeMode = mode === 'real' ? 'real' : 'http';
const httpTplQueue: { index: number; outbound: unknown }[] = [];
const httpSubQueue: { tag: string; outbound: unknown }[] = [];
const enqueue = (
ob: { tag?: string; protocol?: string },
kind: 'tpl' | 'sub',
index: number,
tag: string,
) => {
const proto = ob?.protocol;
if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
// freedom ("direct") and dns aren't proxies — skip them in every mode.
if (proto === 'freedom' || proto === 'dns') return;
if (kind === 'sub' && !tag) return;
const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
if (kind === 'tpl') {
if (toHttp) httpTplQueue.push({ index, outbound: ob });
else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
} else if (toHttp) {
httpSubQueue.push({ tag, outbound: ob });
} else {
tcpQueue.push({ kind: 'sub', tag, outbound: ob });
}
};
templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
// TCP probes are dial-only and cheap server-side; per-item requests
// keep results landing one by one, each routed to its own state map.
const runTcpLane = async () => {
const queue = [...tcpQueue];
const worker = async () => {
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
if (item.kind === 'sub')
await testSubscriptionOutbound(item.tag, item.outbound, mode);
else await testOutbound(item.index, item.outbound, mode);
}
};
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
};
// HTTP probes go out as chunked batches — one temp xray spawn per
// chunk instead of one per outbound, with results landing per chunk.
const runTplHttpLane = async () => {
for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
setOutboundTestStates((prev) => {
const next = { ...prev };
for (const item of chunk)
next[item.index] = { testing: true, result: null, mode: probeMode };
return next;
});
const results = await postOutboundTestBatch(
chunk.map((c) => c.outbound),
probeMode,
);
setOutboundTestStates((prev) => {
const next = { ...prev };
chunk.forEach((item, i) => {
next[item.index] = { testing: false, result: results[i] };
});
return next;
});
}
};
const runSubHttpLane = async () => {
for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
setSubscriptionTestStates((prev) => {
const next = { ...prev };
for (const item of chunk)
next[item.tag] = { testing: true, result: null, mode: probeMode };
return next;
});
const results = await postOutboundTestBatch(
chunk.map((c) => c.outbound),
probeMode,
);
setSubscriptionTestStates((prev) => {
const next = { ...prev };
chunk.forEach((item, i) => {
next[item.tag] = { testing: false, result: results[i] };
});
return next;
});
}
};
// HTTP batches must not overlap: the backend serialises them with a
// non-blocking lock and rejects a second concurrent batch ("Another
// outbound test is already running"). Run the template and subscription
// HTTP lanes one after the other; TCP probes don't take that lock, so
// they still run alongside.
const runHttpLane = async () => {
await runTplHttpLane();
await runSubHttpLane();
};
await Promise.all([runTcpLane(), runHttpLane()]);
} finally {
setTestingAll(false);
}
},
[testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch],
);
const saveDisabled =
savedXraySetting === xraySetting &&
savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
const saveDisabled = savedXraySetting === xraySetting;
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
@@ -491,22 +231,14 @@ export function useXraySetting(): UseXraySettingResult {
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
}),
@@ -519,22 +251,14 @@ export function useXraySetting(): UseXraySettingResult {
setXraySetting,
templateSettings,
setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll,
fetchOutboundsTrafficCb,
resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll,
resetToDefault,
],