GeoBrowserModal.tsx 17 KB

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