GeoBrowserModal.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. import { useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Alert,
  5. Button,
  6. Empty,
  7. Input,
  8. Modal,
  9. Pagination,
  10. Select,
  11. Space,
  12. Table,
  13. Tag,
  14. Tooltip,
  15. Typography,
  16. } from 'antd';
  17. import type { ColumnsType } from 'antd/es/table';
  18. import { useGeodataCategories, useGeodataEntries, useGeodataFiles } from '@/api/queries/useGeodata';
  19. import { canonicalToken, mergeSelection, selectionFromValue, tokenFor } from '@/lib/xray/geoTokens';
  20. import { SizeFormatter } from '@/utils';
  21. import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types';
  22. import './GeoBrowserModal.css';
  23. const ENTRY_PAGE_SIZE = 100;
  24. const CATEGORY_SCROLL_HEIGHT = 438;
  25. const ENTRY_FILTER_DELAY = 500;
  26. export interface GeoBrowserModalProps {
  27. open: boolean;
  28. kind: GeoKind;
  29. value: string;
  30. onApply: (value: string) => void;
  31. onClose: () => void;
  32. }
  33. // A geosite category inside an ip rule (or the reverse) is a config Xray will
  34. // reject, so a field only ever offers databases of its own kind.
  35. function databasesFor(files: GeoFile[], kind: GeoKind): GeoFile[] {
  36. return files.filter(
  37. (file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind)),
  38. );
  39. }
  40. function namePrefersKind(name: string, kind: GeoKind): boolean {
  41. return name.toLowerCase().includes('ip') === (kind === 'ip');
  42. }
  43. function preferredFile(files: GeoFile[], kind: GeoKind): string | undefined {
  44. const usable = databasesFor(files, kind).filter((file) => !file.error);
  45. const preferredName = kind === 'ip' ? 'geoip.dat' : 'geosite.dat';
  46. return usable.find((file) => file.name === preferredName)?.name ?? usable[0]?.name;
  47. }
  48. export default function GeoBrowserModal({
  49. open,
  50. kind,
  51. value,
  52. onApply,
  53. onClose,
  54. }: GeoBrowserModalProps) {
  55. const { t } = useTranslation();
  56. const [file, setFile] = useState<string | undefined>(undefined);
  57. const [categoryQuery, setCategoryQuery] = useState('');
  58. const [activeCode, setActiveCode] = useState<string | undefined>(undefined);
  59. const [entryQuery, setEntryQuery] = useState('');
  60. const [entryFilter, setEntryFilter] = useState('');
  61. const [entryPage, setEntryPage] = useState(1);
  62. const [selected, setSelected] = useState<string[]>([]);
  63. const [known, setKnown] = useState<Set<string>>(() => new Set());
  64. const [seededFiles, setSeededFiles] = useState<Set<string>>(() => new Set());
  65. const filesQuery = useGeodataFiles(open);
  66. const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]);
  67. const activeFile = useMemo(
  68. () => files.find((candidate) => candidate.name === file),
  69. [files, file],
  70. );
  71. const fileKind: GeoKind = useMemo(() => activeFile?.kind ?? kind, [activeFile, kind]);
  72. const categoriesQuery = useGeodataCategories(file, '', open && !!file);
  73. // While a newly picked database loads, the query still serves the previous
  74. // one's categories; seeding or filtering against those would attribute one
  75. // database's codes to another.
  76. const categoriesLoaded = !categoriesQuery.isPlaceholderData && !categoriesQuery.isLoading;
  77. const categories = useMemo(
  78. () => (categoriesLoaded ? (categoriesQuery.data?.items ?? []) : []),
  79. [categoriesLoaded, categoriesQuery.data],
  80. );
  81. // Only the settled filter reaches the query key: every request rescans the
  82. // whole .dat file server-side, so a per-keystroke fetch would be one full
  83. // scan per character while the box itself stays instant.
  84. const entriesQuery = useGeodataEntries(
  85. file,
  86. activeCode,
  87. entryFilter,
  88. (entryPage - 1) * ENTRY_PAGE_SIZE,
  89. ENTRY_PAGE_SIZE,
  90. open && !!file && !!activeCode,
  91. );
  92. // Resets clear both halves at once so a switch of database or category never
  93. // renders with the previous filter still in the key, which would fire the
  94. // very request the debounce exists to avoid.
  95. const clearEntryFilter = useCallback(() => {
  96. setEntryQuery('');
  97. setEntryFilter('');
  98. setEntryPage(1);
  99. }, []);
  100. useEffect(() => {
  101. if (entryQuery === entryFilter) return;
  102. const handle = window.setTimeout(() => {
  103. setEntryFilter(entryQuery);
  104. setEntryPage(1);
  105. }, ENTRY_FILTER_DELAY);
  106. return () => window.clearTimeout(handle);
  107. }, [entryQuery, entryFilter]);
  108. // Opening, picking the default database and seeding the selection are all
  109. // render-time adjustments — an effect would paint the previous state first.
  110. const [wasOpen, setWasOpen] = useState(false);
  111. if (open !== wasOpen) {
  112. setWasOpen(open);
  113. if (open) {
  114. setKnown(new Set());
  115. setSeededFiles(new Set());
  116. setCategoryQuery('');
  117. setEntryQuery('');
  118. setEntryFilter('');
  119. setActiveCode(undefined);
  120. setEntryPage(1);
  121. setSelected([]);
  122. }
  123. } else if (open && !file && files.length > 0) {
  124. setFile(preferredFile(files, kind));
  125. } else if (open && file && categories.length > 0 && !seededFiles.has(file)) {
  126. const tokens = categories.map((category) => tokenFor(file, category.code, fileKind));
  127. setKnown(new Set([...known, ...tokens]));
  128. setSeededFiles(new Set(seededFiles).add(file));
  129. const fromValue = selectionFromValue(value, new Set(tokens));
  130. if (fromValue.length > 0) {
  131. setSelected((previous) => [
  132. ...previous,
  133. ...fromValue.filter((token) => !previous.includes(token)),
  134. ]);
  135. }
  136. }
  137. const visibleCategories = useMemo(() => {
  138. const query = categoryQuery.trim().toLowerCase();
  139. if (!query) return categories;
  140. return categories.filter((category) => category.code.includes(query));
  141. }, [categories, categoryQuery]);
  142. // Comparisons run through the canonical form: a field may hold the long
  143. // ext:geosite.dat:cn spelling or a different case, and those name the same
  144. // category as the geosite:cn this modal generates.
  145. const selectedCodes = useMemo(() => {
  146. if (!file) return [];
  147. const chosen = new Set(selected.map(canonicalToken));
  148. return categories
  149. .filter((category) => chosen.has(canonicalToken(tokenFor(file, category.code, fileKind))))
  150. .map((category) => category.code);
  151. }, [categories, file, fileKind, selected]);
  152. const toggle = useCallback(
  153. (codes: string[]) => {
  154. if (!file) return;
  155. const chosen = new Set(codes.map((code) => tokenFor(file, code, fileKind)));
  156. const chosenCanonical = new Set([...chosen].map(canonicalToken));
  157. // The table reports keys for the rows it currently shows, so a selection
  158. // made before the search box was narrowed must survive untouched.
  159. const shown = new Set(
  160. visibleCategories.map((category) =>
  161. canonicalToken(tokenFor(file, category.code, fileKind)),
  162. ),
  163. );
  164. setSelected((previous) => {
  165. const kept = previous.filter((token) => {
  166. const canonical = canonicalToken(token);
  167. return !shown.has(canonical) || chosenCanonical.has(canonical);
  168. });
  169. const keptCanonical = new Set(kept.map(canonicalToken));
  170. return [
  171. ...kept,
  172. ...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token))),
  173. ];
  174. });
  175. },
  176. [visibleCategories, file, fileKind],
  177. );
  178. const categoryColumns: ColumnsType<GeoCategory> = useMemo(
  179. () => [
  180. {
  181. title: t('pages.xray.geoBrowser.searchCategory'),
  182. dataIndex: 'code',
  183. render: (code: string, category: GeoCategory) => (
  184. <span className="geo-category">
  185. <span className="geo-code">{code}</span>
  186. {category.attributes?.length > 0 && (
  187. <span className="geo-attrs">
  188. {category.attributes.map((attribute) => (
  189. <Tag key={attribute} variant="filled">
  190. @{attribute}
  191. </Tag>
  192. ))}
  193. </span>
  194. )}
  195. </span>
  196. ),
  197. },
  198. {
  199. dataIndex: 'entries',
  200. align: 'right',
  201. width: 90,
  202. render: (entries: number) => <span className="geo-count">{entries.toLocaleString()}</span>,
  203. },
  204. ],
  205. [t],
  206. );
  207. const entryColumns: ColumnsType<GeoEntry> = useMemo(
  208. () => [
  209. {
  210. dataIndex: 'kind',
  211. width: 88,
  212. render: (entryKind: string) => (
  213. <Tag variant="filled" className={`geo-kind geo-kind-${entryKind}`}>
  214. {entryKind}
  215. </Tag>
  216. ),
  217. },
  218. {
  219. dataIndex: 'value',
  220. render: (entryValue: string) => <span className="geo-entry-value">{entryValue}</span>,
  221. },
  222. ],
  223. [],
  224. );
  225. const fileOptions = files.map((candidate) => ({
  226. value: candidate.name,
  227. label: candidate.error
  228. ? `${candidate.name} — ${describeFileError(candidate.error, t)}`
  229. : candidate.name,
  230. disabled: !!candidate.error,
  231. }));
  232. const meta = activeFile
  233. ? t('pages.xray.geoBrowser.fileMeta', {
  234. count: activeFile.categories.toLocaleString(),
  235. size: SizeFormatter.sizeFormat(activeFile.size),
  236. date: new Date(activeFile.modifiedAt).toLocaleString(),
  237. })
  238. : '';
  239. const entriesTotal = entriesQuery.data?.total ?? 0;
  240. const activeCategory = categories.find((category) => category.code === activeCode);
  241. const countLabel = activeCategory
  242. ? t(
  243. fileKind === 'ip'
  244. ? 'pages.xray.geoBrowser.subnetsCount'
  245. : 'pages.xray.geoBrowser.entriesCount',
  246. {
  247. count: activeCategory.entries.toLocaleString(),
  248. },
  249. )
  250. : '';
  251. return (
  252. <Modal
  253. open={open}
  254. title={t('pages.xray.geoBrowser.title')}
  255. width={880}
  256. onCancel={onClose}
  257. onOk={() => onApply(mergeSelection(value, selected, known))}
  258. okText={t('pages.xray.geoBrowser.apply')}
  259. cancelText={t('close')}
  260. className="geo-browser-modal"
  261. >
  262. {filesQuery.isError && (
  263. <Alert
  264. type="error"
  265. showIcon
  266. title={t('pages.xray.geoBrowser.loadFailed')}
  267. className="mb-12"
  268. />
  269. )}
  270. {!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? (
  271. <Empty
  272. description={
  273. <span>
  274. {t('pages.xray.geoBrowser.noFiles')}
  275. <br />
  276. <Typography.Text type="secondary">
  277. {t('pages.xray.geoBrowser.noFilesHint')}
  278. </Typography.Text>
  279. </span>
  280. }
  281. />
  282. ) : (
  283. <>
  284. <div className="geo-toolbar">
  285. <Select
  286. value={file}
  287. options={fileOptions}
  288. onChange={(next) => {
  289. setFile(next);
  290. setActiveCode(undefined);
  291. setCategoryQuery('');
  292. clearEntryFilter();
  293. }}
  294. style={{ minWidth: 200 }}
  295. aria-label={t('pages.xray.geoBrowser.database')}
  296. />
  297. <Input.Search
  298. value={categoryQuery}
  299. onChange={(event) => setCategoryQuery(event.target.value)}
  300. placeholder={t('pages.xray.geoBrowser.searchCategory')}
  301. allowClear
  302. />
  303. <Button
  304. onClick={() =>
  305. toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])
  306. }
  307. disabled={visibleCategories.length === 0}
  308. >
  309. {`${t('pages.xray.geoBrowser.selectFound')} (${visibleCategories.length.toLocaleString()})`}
  310. </Button>
  311. <span className="geo-meta">{meta}</span>
  312. </div>
  313. <div className="geo-columns">
  314. <div className="geo-panel geo-categories">
  315. <Table
  316. size="small"
  317. virtual
  318. showHeader={false}
  319. rowKey="code"
  320. columns={categoryColumns}
  321. dataSource={visibleCategories}
  322. loading={
  323. filesQuery.isLoading ||
  324. categoriesQuery.isLoading ||
  325. categoriesQuery.isPlaceholderData
  326. }
  327. pagination={false}
  328. scroll={{ y: CATEGORY_SCROLL_HEIGHT }}
  329. locale={{ emptyText: t('pages.xray.geoBrowser.noMatches') }}
  330. rowSelection={{
  331. columnWidth: 42,
  332. preserveSelectedRowKeys: true,
  333. selectedRowKeys: selectedCodes,
  334. onChange: (keys) => toggle(keys as string[]),
  335. }}
  336. onRow={(category) => ({
  337. onClick: (event) => {
  338. if ((event.target as HTMLElement).closest('.ant-table-selection-column'))
  339. return;
  340. setActiveCode(category.code);
  341. clearEntryFilter();
  342. },
  343. })}
  344. rowClassName={(category) => (category.code === activeCode ? 'geo-row-active' : '')}
  345. />
  346. </div>
  347. <div className="geo-panel geo-preview">
  348. {activeCode ? (
  349. <>
  350. <div className="geo-preview-head">
  351. <Tooltip title={file ? tokenFor(file, activeCode, fileKind) : activeCode}>
  352. <span className="geo-preview-title">{activeCode}</span>
  353. </Tooltip>
  354. <Typography.Text type="secondary">{countLabel}</Typography.Text>
  355. <Input
  356. value={entryQuery}
  357. onChange={(event) => setEntryQuery(event.target.value)}
  358. placeholder={t('pages.xray.geoBrowser.searchEntries')}
  359. allowClear
  360. className="geo-entry-filter"
  361. />
  362. </div>
  363. <div className="geo-preview-body">
  364. <Table
  365. size="small"
  366. showHeader={false}
  367. rowKey={(entry, index) => `${entry.value}-${index}`}
  368. columns={entryColumns}
  369. dataSource={entriesQuery.data?.items ?? []}
  370. loading={entriesQuery.isLoading}
  371. locale={{
  372. emptyText: entriesQuery.isError
  373. ? t('pages.xray.geoBrowser.loadFailed')
  374. : t('pages.xray.geoBrowser.noMatches'),
  375. }}
  376. pagination={false}
  377. />
  378. </div>
  379. <div className="geo-pager">
  380. <Pagination
  381. current={entryPage}
  382. pageSize={ENTRY_PAGE_SIZE}
  383. total={entriesTotal}
  384. size="small"
  385. showSizeChanger={false}
  386. onChange={setEntryPage}
  387. showTotal={(total, range) =>
  388. t('pages.xray.geoBrowser.shownRange', {
  389. from: range[0].toLocaleString(),
  390. to: range[1].toLocaleString(),
  391. total: total.toLocaleString(),
  392. })
  393. }
  394. />
  395. </div>
  396. </>
  397. ) : (
  398. <div className="geo-placeholder">
  399. <Typography.Text type="secondary">
  400. {t('pages.xray.geoBrowser.pickCategory')}
  401. </Typography.Text>
  402. </div>
  403. )}
  404. </div>
  405. </div>
  406. <div className="geo-footer">
  407. {selected.length === 0 ? (
  408. <Typography.Text type="secondary">
  409. {t('pages.xray.geoBrowser.emptySelection')}
  410. </Typography.Text>
  411. ) : (
  412. <>
  413. <Space size={4} wrap className="geo-chips">
  414. {selected.map((token) => (
  415. <Tag
  416. key={token}
  417. closable
  418. color="processing"
  419. onClose={() =>
  420. setSelected((previous) => previous.filter((item) => item !== token))
  421. }
  422. >
  423. {token}
  424. </Tag>
  425. ))}
  426. </Space>
  427. <span className="geo-selected-count">
  428. {t('pages.xray.geoBrowser.selected', { count: selected.length })}
  429. </span>
  430. <Button type="link" size="small" onClick={() => setSelected([])}>
  431. {t('pages.xray.geoBrowser.clearAll')}
  432. </Button>
  433. </>
  434. )}
  435. </div>
  436. </>
  437. )}
  438. </Modal>
  439. );
  440. }
  441. function describeFileError(error: string, t: (key: string) => string): string {
  442. if (error.includes('too large')) return t('pages.xray.geoBrowser.tooLarge');
  443. return t('pages.xray.geoBrowser.parseFailed');
  444. }