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:
Kuzz007
2026-07-26 17:13:07 +03:00
parent c282c17b5a
commit 2736f9beb3
13 changed files with 692 additions and 2 deletions
@@ -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