import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Button, Empty, Input, Modal, Pagination, Select, Space, Table, Tag, Tooltip, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { useGeodataCategories, useGeodataEntries, useGeodataFiles } from '@/api/queries/useGeodata'; import { canonicalToken, mergeSelection, selectionFromValue, tokenFor } from '@/lib/xray/geoTokens'; import { SizeFormatter } from '@/utils'; import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types'; import './GeoBrowserModal.css'; const ENTRY_PAGE_SIZE = 100; const CATEGORY_SCROLL_HEIGHT = 438; const ENTRY_FILTER_DELAY = 500; export interface GeoBrowserModalProps { open: boolean; kind: GeoKind; value: string; onApply: (value: string) => void; onClose: () => void; } // A geosite category inside an ip rule (or the reverse) is a config Xray will // reject, so a field only ever offers databases of its own kind. function databasesFor(files: GeoFile[], kind: GeoKind): GeoFile[] { return files.filter((file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind))); } function namePrefersKind(name: string, kind: GeoKind): boolean { return name.toLowerCase().includes('ip') === (kind === 'ip'); } function preferredFile(files: GeoFile[], kind: GeoKind): string | undefined { const usable = databasesFor(files, kind).filter((file) => !file.error); const preferredName = kind === 'ip' ? 'geoip.dat' : 'geosite.dat'; return usable.find((file) => file.name === preferredName)?.name ?? usable[0]?.name; } export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: GeoBrowserModalProps) { const { t } = useTranslation(); const [file, setFile] = useState(undefined); const [categoryQuery, setCategoryQuery] = useState(''); const [activeCode, setActiveCode] = useState(undefined); const [entryQuery, setEntryQuery] = useState(''); const [entryFilter, setEntryFilter] = useState(''); const [entryPage, setEntryPage] = useState(1); const [selected, setSelected] = useState([]); const knownRef = useRef>(new Set()); const seededFilesRef = useRef>(new Set()); const filesQuery = useGeodataFiles(open); const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]); const activeFile = files.find((candidate) => candidate.name === file); const fileKind: GeoKind = activeFile?.kind ?? kind; const categoriesQuery = useGeodataCategories(file, '', open && !!file); // While a newly picked database loads, the query still serves the previous // one's categories; seeding or filtering against those would attribute one // database's codes to another. const categoriesLoaded = !categoriesQuery.isPlaceholderData && !categoriesQuery.isLoading; const categories = useMemo( () => (categoriesLoaded ? (categoriesQuery.data?.items ?? []) : []), [categoriesLoaded, categoriesQuery.data], ); // Only the settled filter reaches the query key: every request rescans the // whole .dat file server-side, so a per-keystroke fetch would be one full // scan per character while the box itself stays instant. const entriesQuery = useGeodataEntries( file, activeCode, entryFilter, (entryPage - 1) * ENTRY_PAGE_SIZE, ENTRY_PAGE_SIZE, open && !!file && !!activeCode, ); // Resets clear both halves at once so a switch of database or category never // renders with the previous filter still in the key, which would fire the // very request the debounce exists to avoid. const clearEntryFilter = useCallback(() => { setEntryQuery(''); setEntryFilter(''); setEntryPage(1); }, []); useEffect(() => { if (entryQuery === entryFilter) return; const handle = window.setTimeout(() => { setEntryFilter(entryQuery); setEntryPage(1); }, ENTRY_FILTER_DELAY); return () => window.clearTimeout(handle); }, [entryQuery, entryFilter]); useEffect(() => { if (!open) return; knownRef.current = new Set(); seededFilesRef.current = new Set(); setCategoryQuery(''); setEntryQuery(''); setEntryFilter(''); setActiveCode(undefined); setEntryPage(1); setSelected([]); }, [open]); useEffect(() => { if (!open || file || files.length === 0) return; setFile(preferredFile(files, kind)); }, [open, file, files, kind]); useEffect(() => { if (!open || !file || categories.length === 0 || seededFilesRef.current.has(file)) return; const tokens = categories.map((category) => tokenFor(file, category.code, fileKind)); for (const token of tokens) knownRef.current.add(token); seededFilesRef.current.add(file); const fromValue = selectionFromValue(value, new Set(tokens)); if (fromValue.length > 0) { setSelected((previous) => [...previous, ...fromValue.filter((token) => !previous.includes(token))]); } }, [open, file, categories, fileKind, value]); const visibleCategories = useMemo(() => { const query = categoryQuery.trim().toLowerCase(); if (!query) return categories; return categories.filter((category) => category.code.includes(query)); }, [categories, categoryQuery]); // Comparisons run through the canonical form: a field may hold the long // ext:geosite.dat:cn spelling or a different case, and those name the same // category as the geosite:cn this modal generates. const selectedCodes = useMemo(() => { if (!file) return []; const chosen = new Set(selected.map(canonicalToken)); return categories .filter((category) => chosen.has(canonicalToken(tokenFor(file, category.code, fileKind)))) .map((category) => category.code); }, [categories, file, fileKind, selected]); const toggle = useCallback( (codes: string[]) => { if (!file) return; const chosen = new Set(codes.map((code) => tokenFor(file, code, fileKind))); const chosenCanonical = new Set([...chosen].map(canonicalToken)); // The table reports keys for the rows it currently shows, so a selection // made before the search box was narrowed must survive untouched. const shown = new Set( visibleCategories.map((category) => canonicalToken(tokenFor(file, category.code, fileKind))), ); setSelected((previous) => { const kept = previous.filter((token) => { const canonical = canonicalToken(token); return !shown.has(canonical) || chosenCanonical.has(canonical); }); const keptCanonical = new Set(kept.map(canonicalToken)); return [...kept, ...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token)))]; }); }, [visibleCategories, file, fileKind], ); const categoryColumns: ColumnsType = useMemo( () => [ { title: t('pages.xray.geoBrowser.searchCategory'), dataIndex: 'code', render: (code: string, category: GeoCategory) => ( {code} {category.attributes?.length > 0 && ( {category.attributes.map((attribute) => ( @{attribute} ))} )} ), }, { dataIndex: 'entries', align: 'right', width: 90, render: (entries: number) => {entries.toLocaleString()}, }, ], [t], ); const entryColumns: ColumnsType = useMemo( () => [ { dataIndex: 'kind', width: 88, render: (entryKind: string) => ( {entryKind} ), }, { dataIndex: 'value', render: (entryValue: string) => {entryValue}, }, ], [], ); const fileOptions = files.map((candidate) => ({ value: candidate.name, label: candidate.error ? `${candidate.name} — ${describeFileError(candidate.error, t)}` : candidate.name, disabled: !!candidate.error, })); const meta = activeFile ? t('pages.xray.geoBrowser.fileMeta', { count: activeFile.categories.toLocaleString(), size: SizeFormatter.sizeFormat(activeFile.size), date: new Date(activeFile.modifiedAt).toLocaleString(), }) : ''; const entriesTotal = entriesQuery.data?.total ?? 0; const activeCategory = categories.find((category) => category.code === activeCode); const countLabel = activeCategory ? t(fileKind === 'ip' ? 'pages.xray.geoBrowser.subnetsCount' : 'pages.xray.geoBrowser.entriesCount', { count: activeCategory.entries.toLocaleString(), }) : ''; return ( onApply(mergeSelection(value, selected, knownRef.current))} okText={t('pages.xray.geoBrowser.apply')} cancelText={t('close')} className="geo-browser-modal" > {filesQuery.isError && } {!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? ( {t('pages.xray.geoBrowser.noFiles')}
{t('pages.xray.geoBrowser.noFilesHint')} } /> ) : ( <>
setEntryQuery(event.target.value)} placeholder={t('pages.xray.geoBrowser.searchEntries')} allowClear className="geo-entry-filter" />
`${entry.value}-${index}`} columns={entryColumns} dataSource={entriesQuery.data?.items ?? []} loading={entriesQuery.isLoading} locale={{ emptyText: entriesQuery.isError ? t('pages.xray.geoBrowser.loadFailed') : t('pages.xray.geoBrowser.noMatches'), }} pagination={false} />
t('pages.xray.geoBrowser.shownRange', { from: range[0].toLocaleString(), to: range[1].toLocaleString(), total: total.toLocaleString(), }) } />
) : (
{t('pages.xray.geoBrowser.pickCategory')}
)}
{selected.length === 0 ? ( {t('pages.xray.geoBrowser.emptySelection')} ) : ( <> {selected.map((token) => ( setSelected((previous) => previous.filter((item) => item !== token))} > {token} ))} {t('pages.xray.geoBrowser.selected', { count: selected.length })} )}
)} ); } function describeFileError(error: string, t: (key: string) => string): string { if (error.includes('too large')) return t('pages.xray.geoBrowser.tooLarge'); return t('pages.xray.geoBrowser.parseFailed'); }