mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-27 09:42:10 +03:00
feat(xray): autocomplete geosite/geoip categories in the routing rule editor
Writing a routing rule today means remembering exact geosite:/geoip:/ ext:file:code syntax by hand, with no way to discover what categories actually exist in the .dat files sitting in the bin folder -- including custom ones like geosite_roscom.dat added via the Geodata auto-update feature. The Domain/IP fields in the rule editor now suggest categories as you type (e.g. "you" -> "geosite:youtube"), built live from whatever .dat files are actually on disk, while still accepting any free-typed value exactly as before. Backend: GET /panel/api/xray/getGeodataCategories scans the bin folder, parses matched geosite*/geoip*.dat files via xray-core's own exported protobuf types, and formats each category as the exact rule syntax xray-core's parser accepts -- geosite:/geoip: for the default files, ext:<file>:<code> for anything else (there's no shorthand for custom files). Cached in memory keyed by each file's (name, size, modTime) so a request-time scan is cheap until a file actually changes. Frontend: the Domain/IP inputs become Select "tags" fields fed by a new useGeodataCategories() query hook, with an explicit substring filter so "you" matches "geosite:youtube" (not a prefix). The array<->CSV-string adapter lives entirely at the FormField transform boundary, so the underlying form schema and saved rule shape are unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { GeodataCategoriesSchema, type GeodataCategories } from '@/schemas/routing';
|
||||
|
||||
const EMPTY_CATEGORIES: GeodataCategories = { domain: [], ip: [] };
|
||||
|
||||
async function fetchGeodataCategories(): Promise<GeodataCategories> {
|
||||
const msg = await HttpUtil.get('/panel/api/xray/getGeodataCategories', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories');
|
||||
const validated = parseMsg(msg, GeodataCategoriesSchema, 'xray/getGeodataCategories');
|
||||
return validated.obj ?? EMPTY_CATEGORIES;
|
||||
}
|
||||
|
||||
// Deliberately not staleTime: Infinity like useInboundOptions: geodata .dat
|
||||
// files can change from xray-core's own unattended geodata-update cron,
|
||||
// which has no invalidation hook into the panel. Inheriting the app's
|
||||
// global default staleTime lets a long-open tab pick up newly downloaded
|
||||
// categories on refocus, at near-zero backend cost thanks to the
|
||||
// mtime/size cache in GetGeodataCategories.
|
||||
export function useGeodataCategories() {
|
||||
return useQuery({
|
||||
queryKey: keys.xray.geodataCategories(),
|
||||
queryFn: fetchGeodataCategories,
|
||||
});
|
||||
}
|
||||
@@ -37,5 +37,6 @@ export const keys = {
|
||||
root: () => ['xray'] as const,
|
||||
config: () => ['xray', 'config'] as const,
|
||||
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
|
||||
geodataCategories: () => ['xray', 'geodataCategories'] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -4,6 +4,7 @@ export type ProcessState = string;
|
||||
export type Protocol = string;
|
||||
export type SubLinkProvider = unknown;
|
||||
export type ensureAction = number;
|
||||
export type geodataFileKind = number;
|
||||
export type staticEgressResolver = string;
|
||||
export type transportBits = number;
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
||||
export const ensureActionSchema = z.number().int();
|
||||
export type ensureAction = z.infer<typeof ensureActionSchema>;
|
||||
|
||||
export const geodataFileKindSchema = z.number().int();
|
||||
export type geodataFileKind = z.infer<typeof geodataFileKindSchema>;
|
||||
|
||||
export const staticEgressResolverSchema = z.string();
|
||||
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||
|
||||
|
||||
@@ -1277,6 +1277,12 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/xray/getXrayResult',
|
||||
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/xray/getGeodataCategories',
|
||||
summary: 'Return every geosite/geoip category found in the .dat files currently present in the Xray bin folder (including custom files added via the Geodata auto-update feature), formatted as ready-to-use routing rule values, e.g. "geosite:youtube" or "ext:geosite_roscom.dat:some-code".',
|
||||
response: '{\n "success": true,\n "obj": {\n "domain": ["geosite:cn", "geosite:youtube"],\n "ip": ["geoip:cn", "geoip:private"]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/update',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import { useGeodataCategories } from '@/api/queries/useGeodataCategories';
|
||||
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
|
||||
import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
|
||||
|
||||
@@ -63,6 +64,24 @@ function csv(value: string): string[] {
|
||||
return value.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Domain/IP are stored as a comma-joined string (RuleFormSchema.domain/ip),
|
||||
// same as every other csv-backed field on this form, but rendered as a
|
||||
// Select "tags" input so geosite/geoip suggestions can be picked alongside
|
||||
// free-typed values. These adapt between the two shapes at the FormField
|
||||
// transform boundary only -- the stored form value never becomes an array.
|
||||
function toTagsArray(value: unknown): string[] {
|
||||
return csv(typeof value === 'string' ? value : '');
|
||||
}
|
||||
function fromTagsArray(value: unknown): string {
|
||||
return Array.isArray(value) ? value.join(',') : '';
|
||||
}
|
||||
// Explicit substring match: typing "you" must match the suggestion
|
||||
// "geosite:youtube", which isn't a prefix match since it starts with
|
||||
// "geosite:". AntD's default filterOption behavior isn't relied on.
|
||||
function filterBySubstring(input: string, option?: { value?: string }): boolean {
|
||||
return typeof option?.value === 'string' && option.value.toLowerCase().includes(input.toLowerCase());
|
||||
}
|
||||
|
||||
export default function RuleFormModal({
|
||||
open,
|
||||
rule,
|
||||
@@ -79,6 +98,16 @@ export default function RuleFormModal({
|
||||
const { data: inboundOptions } = useInboundOptions();
|
||||
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
|
||||
|
||||
const { data: geodataCategories } = useGeodataCategories();
|
||||
const domainOptions = useMemo(
|
||||
() => (geodataCategories?.domain ?? []).map((value) => ({ value, label: value })),
|
||||
[geodataCategories],
|
||||
);
|
||||
const ipOptions = useMemo(
|
||||
() => (geodataCategories?.ip ?? []).map((value) => ({ value, label: value })),
|
||||
[geodataCategories],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (rule) {
|
||||
@@ -252,8 +281,15 @@ export default function RuleFormModal({
|
||||
IP <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
transform={{ input: toTagsArray, output: fromTagsArray }}
|
||||
>
|
||||
<Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||
<Select
|
||||
mode="tags"
|
||||
options={ipOptions}
|
||||
tokenSeparators={[',']}
|
||||
filterOption={filterBySubstring}
|
||||
placeholder="0.0.0.0/8, fc00::/7, geoip:ir"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
@@ -263,8 +299,15 @@ export default function RuleFormModal({
|
||||
{t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
|
||||
</Tooltip>
|
||||
}
|
||||
transform={{ input: toTagsArray, output: fromTagsArray }}
|
||||
>
|
||||
<Input placeholder="google.com, geosite:cn" />
|
||||
<Select
|
||||
mode="tags"
|
||||
options={domainOptions}
|
||||
tokenSeparators={[',']}
|
||||
filterOption={filterBySubstring}
|
||||
placeholder="google.com, geosite:cn"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
|
||||
@@ -39,6 +39,17 @@ export const RuleObjectSchema = z.object({
|
||||
});
|
||||
export type RuleObject = z.infer<typeof RuleObjectSchema>;
|
||||
|
||||
// Response shape of GET /panel/api/xray/getGeodataCategories: every
|
||||
// geosite/geoip category found in the .dat files currently present in the
|
||||
// Xray bin folder, already formatted as ready-to-use rule values (e.g.
|
||||
// "geosite:youtube", "ext:geosite_roscom.dat:some-code") for the routing
|
||||
// rule editor's Domain/IP autocomplete.
|
||||
export const GeodataCategoriesSchema = z.object({
|
||||
domain: z.array(z.string()).nullable().transform((v) => v ?? []),
|
||||
ip: z.array(z.string()).nullable().transform((v) => v ?? []),
|
||||
});
|
||||
export type GeodataCategories = z.infer<typeof GeodataCategoriesSchema>;
|
||||
|
||||
export const BalancerStrategyTypeSchema = z.enum([
|
||||
'random',
|
||||
'roundRobin',
|
||||
|
||||
Reference in New Issue
Block a user