diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 14c0cb997..ae34f7704 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -10452,6 +10452,49 @@ } } }, + "/panel/api/xray/getGeodataCategories": { + "get": { + "tags": [ + "Xray Settings" + ], + "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\".", + "operationId": "get_panel_api_xray_getGeodataCategories", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": { + "domain": [ + "geosite:cn", + "geosite:youtube" + ], + "ip": [ + "geoip:cn", + "geoip:private" + ] + } + } + } + } + } + } + } + }, "/panel/api/xray/update": { "post": { "tags": [ diff --git a/frontend/src/api/queries/useGeodataCategories.ts b/frontend/src/api/queries/useGeodataCategories.ts new file mode 100644 index 000000000..ffb5aeaf3 --- /dev/null +++ b/frontend/src/api/queries/useGeodataCategories.ts @@ -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 { + 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, + }); +} diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 4166c28c1..a9c57de89 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -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; diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index eec2ce600..e8a1dbe83 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index ac0625d3a..ac218b67a 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -15,6 +15,9 @@ export type SubLinkProvider = z.infer; export const ensureActionSchema = z.number().int(); export type ensureAction = z.infer; +export const geodataFileKindSchema = z.number().int(); +export type geodataFileKind = z.infer; + export const staticEgressResolverSchema = z.string(); export type staticEgressResolver = z.infer; diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 1d2c3c3b9..eaa01d05e 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -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', diff --git a/frontend/src/pages/xray/routing/RuleFormModal.tsx b/frontend/src/pages/xray/routing/RuleFormModal.tsx index f214305cc..95954ac70 100644 --- a/frontend/src/pages/xray/routing/RuleFormModal.tsx +++ b/frontend/src/pages/xray/routing/RuleFormModal.tsx @@ -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