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)
267 lines
8.5 KiB
TypeScript
267 lines
8.5 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { z } from 'zod';
|
|
|
|
import { HttpUtil, Msg } from '@/utils';
|
|
import { parseMsg } from '@/utils/zodValidate';
|
|
import { keys } from '@/api/queryKeys';
|
|
import {
|
|
OutboundTrafficListSchema,
|
|
XrayConfigPayloadSchema,
|
|
XraySettingsValueSchema,
|
|
type OutboundTrafficRow,
|
|
} from '@/schemas/xray';
|
|
|
|
export type { OutboundTrafficRow };
|
|
|
|
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
|
|
|
|
export type SetTemplate = (
|
|
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
|
|
) => void;
|
|
|
|
export interface UseXraySettingResult {
|
|
fetched: boolean;
|
|
spinning: boolean;
|
|
saveDisabled: boolean;
|
|
fetchError: string;
|
|
xraySetting: string;
|
|
setXraySetting: (next: string) => void;
|
|
templateSettings: XraySettingsValue | null;
|
|
setTemplateSettings: SetTemplate;
|
|
inboundTags: string[];
|
|
clientReverseTags: string[];
|
|
subscriptionOutbounds: unknown[];
|
|
subscriptionOutboundTags: string[];
|
|
outboundsTraffic: OutboundTrafficRow[];
|
|
fetchAll: () => Promise<void>;
|
|
fetchOutboundsTraffic: () => Promise<void>;
|
|
resetOutboundsTraffic: (tag: string) => Promise<void>;
|
|
saveAll: () => Promise<void>;
|
|
resetToDefault: () => Promise<void>;
|
|
}
|
|
|
|
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
|
|
|
export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
|
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
|
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
|
if (typeof msg.obj !== 'string')
|
|
throw new Error('Malformed xray config response: expected string');
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(msg.obj);
|
|
} catch (e) {
|
|
const err = e as Error;
|
|
throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
|
|
}
|
|
const result = XrayConfigPayloadSchema.safeParse(parsed);
|
|
if (!result.success) {
|
|
console.warn('[zod] xray/ config payload failed validation', result.error.issues);
|
|
return parsed as XrayConfigPayload;
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
|
|
const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, {
|
|
silent: true,
|
|
});
|
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
|
|
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
|
|
return Array.isArray(validated.obj) ? validated.obj : [];
|
|
}
|
|
|
|
export function useXraySetting(): UseXraySettingResult {
|
|
const queryClient = useQueryClient();
|
|
|
|
const configQuery = useQuery({
|
|
queryKey: keys.xray.config(),
|
|
queryFn: fetchXrayConfig,
|
|
staleTime: Infinity,
|
|
});
|
|
|
|
const trafficQuery = useQuery({
|
|
queryKey: keys.xray.outboundsTraffic(),
|
|
queryFn: fetchOutboundsTraffic,
|
|
staleTime: Infinity,
|
|
});
|
|
|
|
const [xraySetting, setXraySettingState] = useState('');
|
|
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
|
const [savedXraySetting, setSavedXraySetting] = useState('');
|
|
const config = configQuery.data;
|
|
const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
|
|
const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
|
|
const subscriptionOutbounds = useMemo<unknown[]>(
|
|
() => config?.subscriptionOutbounds || [],
|
|
[config],
|
|
);
|
|
const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [config]);
|
|
|
|
const syncingRef = useRef(false);
|
|
const xraySettingRef = useRef('');
|
|
const savedXraySettingRef = useRef(savedXraySetting);
|
|
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
|
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
|
|
|
const [syncedConfig, setSyncedConfig] = useState<XrayConfigPayload | undefined>();
|
|
|
|
useEffect(() => {
|
|
xraySettingRef.current = xraySetting;
|
|
savedXraySettingRef.current = savedXraySetting;
|
|
templateSettingsRef.current = templateSettings;
|
|
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
|
});
|
|
|
|
// Adopt a fetched config during render, so the editor never paints one frame
|
|
// of the previous config after a refetch. Local edits win over the refetch.
|
|
if (config && config !== syncedConfig) {
|
|
setSyncedConfig(config);
|
|
const isDirty = savedXraySetting !== xraySetting;
|
|
if (!isDirty) {
|
|
const pretty = JSON.stringify(config.xraySetting, null, 2);
|
|
setXraySettingState(pretty);
|
|
setTemplateSettingsState(config.xraySetting);
|
|
setSavedXraySetting(pretty);
|
|
}
|
|
}
|
|
|
|
const fetched = configQuery.data !== undefined || configQuery.isError;
|
|
const fetchError = configQuery.error ? (configQuery.error as Error).message : '';
|
|
|
|
const setXraySetting = useCallback((next: string) => {
|
|
setXraySettingState(next);
|
|
if (syncingRef.current) return;
|
|
try {
|
|
const parsed = JSON.parse(next);
|
|
syncingRef.current = true;
|
|
setTemplateSettingsState(parsed);
|
|
syncingRef.current = false;
|
|
} catch {
|
|
/* ignore — wait for user to finish */
|
|
}
|
|
}, []);
|
|
|
|
const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
|
|
setTemplateSettingsState((prev) => {
|
|
const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
|
|
if (next == null) return next;
|
|
if (!syncingRef.current) {
|
|
try {
|
|
syncingRef.current = true;
|
|
setXraySettingState(JSON.stringify(next, null, 2));
|
|
} finally {
|
|
syncingRef.current = false;
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const fetchAll = useCallback(async () => {
|
|
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
|
}, [queryClient]);
|
|
|
|
const fetchOutboundsTrafficCb = useCallback(async () => {
|
|
await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
|
}, [queryClient]);
|
|
|
|
const saveMut = useMutation({
|
|
mutationFn: async () => {
|
|
const sentXraySetting = xraySettingRef.current;
|
|
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
|
xraySetting: sentXraySetting,
|
|
});
|
|
return { msg, sentXraySetting };
|
|
},
|
|
onSuccess: ({ msg, sentXraySetting }) => {
|
|
if (!msg?.success) return;
|
|
setSavedXraySetting(sentXraySetting);
|
|
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
|
},
|
|
});
|
|
|
|
const resetTrafficMut = useMutation({
|
|
mutationFn: (tag: string) => HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
|
|
onSuccess: (msg) => {
|
|
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
|
},
|
|
});
|
|
|
|
const resetDefaultMut = useMutation({
|
|
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
|
|
const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
|
|
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
|
|
},
|
|
onSuccess: (msg) => {
|
|
if (msg?.success && msg.obj) {
|
|
const cloned = JSON.parse(JSON.stringify(msg.obj));
|
|
setTemplateSettings(cloned);
|
|
}
|
|
},
|
|
});
|
|
|
|
const saveAll = useCallback(async () => {
|
|
await saveMut.mutateAsync();
|
|
}, [saveMut]);
|
|
const resetOutboundsTraffic = useCallback(
|
|
async (tag: string) => {
|
|
await resetTrafficMut.mutateAsync(tag);
|
|
},
|
|
[resetTrafficMut],
|
|
);
|
|
const resetToDefault = useCallback(async () => {
|
|
await resetDefaultMut.mutateAsync();
|
|
}, [resetDefaultMut]);
|
|
|
|
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
|
|
|
const saveDisabled = savedXraySetting === xraySetting;
|
|
|
|
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
|
|
|
return useMemo(
|
|
() => ({
|
|
fetched,
|
|
spinning,
|
|
saveDisabled,
|
|
fetchError,
|
|
xraySetting,
|
|
setXraySetting,
|
|
templateSettings,
|
|
setTemplateSettings,
|
|
inboundTags,
|
|
clientReverseTags,
|
|
subscriptionOutbounds,
|
|
subscriptionOutboundTags,
|
|
outboundsTraffic,
|
|
fetchAll,
|
|
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
|
|
resetOutboundsTraffic,
|
|
saveAll,
|
|
resetToDefault,
|
|
}),
|
|
[
|
|
fetched,
|
|
spinning,
|
|
saveDisabled,
|
|
fetchError,
|
|
xraySetting,
|
|
setXraySetting,
|
|
templateSettings,
|
|
setTemplateSettings,
|
|
inboundTags,
|
|
clientReverseTags,
|
|
subscriptionOutbounds,
|
|
subscriptionOutboundTags,
|
|
outboundsTraffic,
|
|
fetchAll,
|
|
fetchOutboundsTrafficCb,
|
|
resetOutboundsTraffic,
|
|
saveAll,
|
|
resetToDefault,
|
|
],
|
|
);
|
|
}
|