From 277838d7ca840b5c2c97e70c8addc750136497f4 Mon Sep 17 00:00:00 2001 From: Muse Date: Thu, 22 Oct 2026 23:40:54 +0000 Subject: [PATCH] =?UTF-8?q?ITEM6=20B4:=20frontend=20reshaping=20=E2=80=94?= =?UTF-8?q?=20xray=20page=20retired,=20stats=20switches=20to=20settings,?= =?UTF-8?q?=20template=20read=20paths=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete the xray settings page (routes/menu/title), geodata UI/service, XraySettingController/Service and routing-sync write paths; the stored xrayTemplateConfig row loses its last readers (GetXrayConfigTemplate now returns the embedded factory template; B1 already deleted the row). - Settings GeneralTab gains the four trafficStatsSwitches switches (settings.trafficStatsSwitches, B1) with the dual warning copy; entity AllSetting carries the key end to end (TestTrafficStatsSwitchesPlumbing). - Tag-directory consumers (panelOutbound, mtproto egress, node/host outbound pickers) move from POST /xray/ to GET /server/outboundDirectory. - Shared Mux/Sockopt forms and balancer helpers relocated to lib/xray; converter preserves sniffing ips/domains exclusions into the panel sidecar instead of dropping them (B2 gap closed by retention). - Freedom final-rules DB fix seeders retired; getDefaultJsonConfig endpoint removed (no in-repo consumer); locales pruned to referenced keys (pages.xray 331 -> 68) plus the five new settings keys. --- frontend/src/api/queries/useGeodata.ts | 111 --- frontend/src/api/queries/useOutboundTags.ts | 64 +- frontend/src/api/queryKeys.ts | 14 +- frontend/src/api/websocketBridge.ts | 7 - .../components/geodata/GeoBrowserModal.css | 221 ----- .../geodata/GeoBrowserModal.stories.tsx | 864 ---------------- .../components/geodata/GeoBrowserModal.tsx | 473 --------- .../geodata/GeoTokenInput.stories.tsx | 296 ------ .../src/components/geodata/GeoTokenInput.tsx | 140 --- frontend/src/components/geodata/index.ts | 4 - frontend/src/generated/examples.ts | 25 +- frontend/src/generated/schemas.ts | 94 +- frontend/src/generated/types.ts | 18 +- frontend/src/generated/zod.ts | 19 +- frontend/src/hooks/useClients.ts | 1 - frontend/src/hooks/usePageTitle.ts | 3 - frontend/src/hooks/useXraySetting.ts | 266 ----- frontend/src/layouts/AppSidebar.tsx | 24 +- .../xray}/balancer-helpers.ts | 16 +- .../src/lib/xray/forms/SniffingFields.tsx | 4 +- .../outbound}/outbound-form-constants.ts | 0 .../forms/outbound}/outbound-form-helpers.ts | 0 .../xray/forms/outbound/transport/index.ts | 2 + .../xray/forms/outbound}/transport/mux.tsx | 0 .../forms/outbound}/transport/sockopt.tsx | 0 frontend/src/lib/xray/geoTokens.ts | 81 -- frontend/src/models/setting.ts | 2 +- .../pages/hosts/json-forms/HostMuxForm.tsx | 2 +- .../hosts/json-forms/HostSockoptForm.tsx | 2 +- frontend/src/pages/inbounds/useInbounds.ts | 5 +- frontend/src/pages/settings/GeneralTab.tsx | 141 ++- .../settings/SubscriptionBalancersTab.tsx | 2 +- .../src/pages/xray/DeletionImpactList.tsx | 40 - frontend/src/pages/xray/XrayPage.css | 8 - frontend/src/pages/xray/XrayPage.tsx | 286 ------ .../pages/xray/balancers/balancer-loopback.ts | 229 ----- frontend/src/pages/xray/basics/BasicsTab.css | 3 - frontend/src/pages/xray/basics/BasicsTab.tsx | 499 ---------- frontend/src/pages/xray/basics/constants.ts | 68 -- frontend/src/pages/xray/basics/helpers.ts | 121 --- frontend/src/pages/xray/basics/index.ts | 1 - .../pages/xray/outbounds/OutboundCardList.tsx | 141 --- .../xray/outbounds/OutboundFormModal.css | 18 - .../xray/outbounds/OutboundFormModal.tsx | 598 ----------- .../src/pages/xray/outbounds/OutboundsTab.css | 276 ------ .../src/pages/xray/outbounds/OutboundsTab.tsx | 935 ------------------ .../xray/outbounds/SubscriptionOutbounds.tsx | 156 --- frontend/src/pages/xray/outbounds/index.ts | 2 - .../xray/outbounds/outbounds-tab-helpers.ts | 78 -- .../xray/outbounds/outbounds-tab-types.ts | 7 - .../xray/outbounds/protocols/blackhole.tsx | 19 - .../pages/xray/outbounds/protocols/dns.tsx | 84 -- .../xray/outbounds/protocols/freedom.tsx | 289 ------ .../pages/xray/outbounds/protocols/http.tsx | 22 - .../pages/xray/outbounds/protocols/index.ts | 12 - .../xray/outbounds/protocols/loopback.tsx | 31 - .../outbounds/protocols/server-target.tsx | 28 - .../xray/outbounds/protocols/shadowsocks.tsx | 40 - .../pages/xray/outbounds/protocols/socks.tsx | 18 - .../pages/xray/outbounds/protocols/trojan.tsx | 18 - .../pages/xray/outbounds/protocols/vless.tsx | 33 - .../pages/xray/outbounds/protocols/vmess.tsx | 29 - .../xray/outbounds/protocols/wireguard.tsx | 164 --- .../pages/xray/outbounds/security/index.ts | 2 - .../pages/xray/outbounds/security/reality.tsx | 44 - .../src/pages/xray/outbounds/security/tls.tsx | 42 - .../pages/xray/outbounds/transport/grpc.tsx | 31 - .../xray/outbounds/transport/httpupgrade.tsx | 25 - .../xray/outbounds/transport/hysteria.tsx | 113 --- .../pages/xray/outbounds/transport/index.ts | 9 - .../pages/xray/outbounds/transport/kcp.tsx | 45 - .../pages/xray/outbounds/transport/raw.tsx | 109 -- .../src/pages/xray/outbounds/transport/ws.tsx | 31 - .../pages/xray/outbounds/transport/xhttp.tsx | 343 ------- .../xray/outbounds/useOutboundColumns.tsx | 198 ---- .../src/pages/xray/overrides/NordModal.css | 41 - .../src/pages/xray/overrides/NordModal.tsx | 448 --------- .../src/pages/xray/overrides/PiaModal.css | 53 - .../src/pages/xray/overrides/PiaModal.tsx | 468 --------- .../src/pages/xray/overrides/WarpModal.css | 35 - .../src/pages/xray/overrides/WarpModal.tsx | 546 ---------- frontend/src/pages/xray/overrides/index.ts | 3 - frontend/src/pages/xray/reference-cleanup.ts | 245 ----- .../src/pages/xray/routing/CriterionRow.tsx | 27 - .../src/pages/xray/routing/RoutingBasic.tsx | 196 ---- .../src/pages/xray/routing/RoutingTab.css | 243 ----- .../src/pages/xray/routing/RoutingTab.tsx | 461 --------- .../src/pages/xray/routing/RuleCardList.tsx | 189 ---- .../src/pages/xray/routing/RuleFormModal.tsx | 368 ------- frontend/src/pages/xray/routing/helpers.ts | 103 -- frontend/src/pages/xray/routing/index.ts | 2 - frontend/src/pages/xray/routing/types.ts | 17 - .../pages/xray/routing/useRoutingColumns.tsx | 329 ------ frontend/src/routes.tsx | 4 - frontend/src/schemas/setting.ts | 2 +- frontend/src/schemas/xray.ts | 108 -- frontend/src/test/balancer-loopback.test.ts | 385 -------- .../test/balancer-observatory-sync.test.ts | 455 --------- .../src/test/basics-happy-eyeballs.test.tsx | 34 - .../src/test/geo-browser-selection.test.tsx | 260 ----- frontend/src/test/geo-tokens.test.ts | 262 ----- .../src/test/outbound-form-modal.test.tsx | 104 -- frontend/src/test/outbound-tag-rename.test.ts | 64 -- .../test/outbounds-loopback-index.test.tsx | 88 -- frontend/src/test/pia-modal.test.tsx | 391 -------- .../src/test/routing-default-outbound.test.ts | 58 -- .../src/test/routing-loopback-index.test.tsx | 57 -- .../test/routing-reference-cleanup.test.ts | 425 -------- .../test/rule-form-preserve-fields.test.tsx | 128 --- frontend/src/test/use-xray-setting.test.tsx | 50 - frontend/src/test/warp-change-ip.test.ts | 112 --- .../test/websocket-bridge-outbounds.test.tsx | 51 - frontend/src/test/zodValidate.test.ts | 20 +- internal/database/db.go | 211 +--- .../freedom_finalrules_migration_test.go | 139 --- internal/singbox/convert.go | 19 + internal/singbox/convert_test.go | 43 + internal/web/controller/api.go | 8 +- internal/web/controller/geodata_test.go | 303 ------ internal/web/controller/server.go | 58 ++ internal/web/controller/setting.go | 11 - internal/web/controller/xray_setting.go | 484 --------- .../web/controller/xray_setting_warp_test.go | 71 -- internal/web/entity/entity.go | 6 + internal/web/service/forwarding_test.go | 12 +- internal/web/service/geodata.go | 156 --- internal/web/service/inbound.go | 18 - internal/web/service/inbound_traffic.go | 5 - internal/web/service/integration/warp.go | 8 +- internal/web/service/setting.go | 25 +- .../service/traffic_stats_switches_test.go | 122 +++ internal/web/service/xray_core_helpers.go | 93 ++ internal/web/service/xray_setting.go | 377 ------- .../web/service/xray_setting_dns_routing.go | 416 -------- .../service/xray_setting_dns_routing_test.go | 467 --------- .../web/service/xray_setting_routing_sync.go | 311 ------ .../service/xray_setting_routing_sync_test.go | 335 ------- internal/web/service/xray_setting_test.go | 253 ----- internal/web/translation/ar-EG.json | 307 +----- internal/web/translation/en-US.json | 307 +----- internal/web/translation/es-ES.json | 307 +----- internal/web/translation/fa-IR.json | 307 +----- internal/web/translation/id-ID.json | 307 +----- internal/web/translation/ja-JP.json | 307 +----- internal/web/translation/pt-BR.json | 307 +----- internal/web/translation/ru-RU.json | 307 +----- internal/web/translation/tr-TR.json | 307 +----- internal/web/translation/uk-UA.json | 307 +----- internal/web/translation/vi-VN.json | 307 +----- internal/web/translation/zh-CN.json | 307 +----- internal/web/translation/zh-TW.json | 307 +----- 151 files changed, 780 insertions(+), 22140 deletions(-) delete mode 100644 frontend/src/api/queries/useGeodata.ts delete mode 100644 frontend/src/components/geodata/GeoBrowserModal.css delete mode 100644 frontend/src/components/geodata/GeoBrowserModal.stories.tsx delete mode 100644 frontend/src/components/geodata/GeoBrowserModal.tsx delete mode 100644 frontend/src/components/geodata/GeoTokenInput.stories.tsx delete mode 100644 frontend/src/components/geodata/GeoTokenInput.tsx delete mode 100644 frontend/src/components/geodata/index.ts delete mode 100644 frontend/src/hooks/useXraySetting.ts rename frontend/src/{pages/xray/balancers => lib/xray}/balancer-helpers.ts (84%) rename frontend/src/{pages/xray/outbounds => lib/xray/forms/outbound}/outbound-form-constants.ts (100%) rename frontend/src/{pages/xray/outbounds => lib/xray/forms/outbound}/outbound-form-helpers.ts (100%) create mode 100644 frontend/src/lib/xray/forms/outbound/transport/index.ts rename frontend/src/{pages/xray/outbounds => lib/xray/forms/outbound}/transport/mux.tsx (100%) rename frontend/src/{pages/xray/outbounds => lib/xray/forms/outbound}/transport/sockopt.tsx (100%) delete mode 100644 frontend/src/lib/xray/geoTokens.ts delete mode 100644 frontend/src/pages/xray/DeletionImpactList.tsx delete mode 100644 frontend/src/pages/xray/XrayPage.css delete mode 100644 frontend/src/pages/xray/XrayPage.tsx delete mode 100644 frontend/src/pages/xray/balancers/balancer-loopback.ts delete mode 100644 frontend/src/pages/xray/basics/BasicsTab.css delete mode 100644 frontend/src/pages/xray/basics/BasicsTab.tsx delete mode 100644 frontend/src/pages/xray/basics/constants.ts delete mode 100644 frontend/src/pages/xray/basics/helpers.ts delete mode 100644 frontend/src/pages/xray/basics/index.ts delete mode 100644 frontend/src/pages/xray/outbounds/OutboundCardList.tsx delete mode 100644 frontend/src/pages/xray/outbounds/OutboundFormModal.css delete mode 100644 frontend/src/pages/xray/outbounds/OutboundFormModal.tsx delete mode 100644 frontend/src/pages/xray/outbounds/OutboundsTab.css delete mode 100644 frontend/src/pages/xray/outbounds/OutboundsTab.tsx delete mode 100644 frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx delete mode 100644 frontend/src/pages/xray/outbounds/index.ts delete mode 100644 frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts delete mode 100644 frontend/src/pages/xray/outbounds/outbounds-tab-types.ts delete mode 100644 frontend/src/pages/xray/outbounds/protocols/blackhole.tsx delete mode 100644 frontend/src/pages/xray/outbounds/protocols/dns.tsx delete mode 100644 frontend/src/pages/xray/outbounds/protocols/freedom.tsx delete mode 100644 frontend/src/pages/xray/outbounds/protocols/http.tsx delete mode 100644 frontend/src/pages/xray/outbounds/protocols/index.ts delete mode 100644 frontend/src/pages/xray/outbounds/protocols/loopback.tsx delete mode 100644 frontend/src/pages/xray/outbounds/protocols/server-target.tsx delete mode 100644 frontend/src/pages/xray/outbounds/protocols/shadowsocks.tsx delete mode 100644 frontend/src/pages/xray/outbounds/protocols/socks.tsx delete mode 100644 frontend/src/pages/xray/outbounds/protocols/trojan.tsx delete mode 100644 frontend/src/pages/xray/outbounds/protocols/vless.tsx delete mode 100644 frontend/src/pages/xray/outbounds/protocols/vmess.tsx delete mode 100644 frontend/src/pages/xray/outbounds/protocols/wireguard.tsx delete mode 100644 frontend/src/pages/xray/outbounds/security/index.ts delete mode 100644 frontend/src/pages/xray/outbounds/security/reality.tsx delete mode 100644 frontend/src/pages/xray/outbounds/security/tls.tsx delete mode 100644 frontend/src/pages/xray/outbounds/transport/grpc.tsx delete mode 100644 frontend/src/pages/xray/outbounds/transport/httpupgrade.tsx delete mode 100644 frontend/src/pages/xray/outbounds/transport/hysteria.tsx delete mode 100644 frontend/src/pages/xray/outbounds/transport/index.ts delete mode 100644 frontend/src/pages/xray/outbounds/transport/kcp.tsx delete mode 100644 frontend/src/pages/xray/outbounds/transport/raw.tsx delete mode 100644 frontend/src/pages/xray/outbounds/transport/ws.tsx delete mode 100644 frontend/src/pages/xray/outbounds/transport/xhttp.tsx delete mode 100644 frontend/src/pages/xray/outbounds/useOutboundColumns.tsx delete mode 100644 frontend/src/pages/xray/overrides/NordModal.css delete mode 100644 frontend/src/pages/xray/overrides/NordModal.tsx delete mode 100644 frontend/src/pages/xray/overrides/PiaModal.css delete mode 100644 frontend/src/pages/xray/overrides/PiaModal.tsx delete mode 100644 frontend/src/pages/xray/overrides/WarpModal.css delete mode 100644 frontend/src/pages/xray/overrides/WarpModal.tsx delete mode 100644 frontend/src/pages/xray/overrides/index.ts delete mode 100644 frontend/src/pages/xray/reference-cleanup.ts delete mode 100644 frontend/src/pages/xray/routing/CriterionRow.tsx delete mode 100644 frontend/src/pages/xray/routing/RoutingBasic.tsx delete mode 100644 frontend/src/pages/xray/routing/RoutingTab.css delete mode 100644 frontend/src/pages/xray/routing/RoutingTab.tsx delete mode 100644 frontend/src/pages/xray/routing/RuleCardList.tsx delete mode 100644 frontend/src/pages/xray/routing/RuleFormModal.tsx delete mode 100644 frontend/src/pages/xray/routing/helpers.ts delete mode 100644 frontend/src/pages/xray/routing/index.ts delete mode 100644 frontend/src/pages/xray/routing/types.ts delete mode 100644 frontend/src/pages/xray/routing/useRoutingColumns.tsx delete mode 100644 frontend/src/schemas/xray.ts delete mode 100644 frontend/src/test/balancer-loopback.test.ts delete mode 100644 frontend/src/test/balancer-observatory-sync.test.ts delete mode 100644 frontend/src/test/basics-happy-eyeballs.test.tsx delete mode 100644 frontend/src/test/geo-browser-selection.test.tsx delete mode 100644 frontend/src/test/geo-tokens.test.ts delete mode 100644 frontend/src/test/outbound-form-modal.test.tsx delete mode 100644 frontend/src/test/outbound-tag-rename.test.ts delete mode 100644 frontend/src/test/outbounds-loopback-index.test.tsx delete mode 100644 frontend/src/test/pia-modal.test.tsx delete mode 100644 frontend/src/test/routing-default-outbound.test.ts delete mode 100644 frontend/src/test/routing-loopback-index.test.tsx delete mode 100644 frontend/src/test/routing-reference-cleanup.test.ts delete mode 100644 frontend/src/test/rule-form-preserve-fields.test.tsx delete mode 100644 frontend/src/test/use-xray-setting.test.tsx delete mode 100644 frontend/src/test/warp-change-ip.test.ts delete mode 100644 frontend/src/test/websocket-bridge-outbounds.test.tsx delete mode 100644 internal/database/freedom_finalrules_migration_test.go delete mode 100644 internal/web/controller/geodata_test.go delete mode 100644 internal/web/controller/xray_setting.go delete mode 100644 internal/web/controller/xray_setting_warp_test.go delete mode 100644 internal/web/service/geodata.go create mode 100644 internal/web/service/traffic_stats_switches_test.go create mode 100644 internal/web/service/xray_core_helpers.go delete mode 100644 internal/web/service/xray_setting.go delete mode 100644 internal/web/service/xray_setting_dns_routing.go delete mode 100644 internal/web/service/xray_setting_dns_routing_test.go delete mode 100644 internal/web/service/xray_setting_routing_sync.go delete mode 100644 internal/web/service/xray_setting_routing_sync_test.go delete mode 100644 internal/web/service/xray_setting_test.go diff --git a/frontend/src/api/queries/useGeodata.ts b/frontend/src/api/queries/useGeodata.ts deleted file mode 100644 index 8f94f78..0000000 --- a/frontend/src/api/queries/useGeodata.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query'; -import { z } from 'zod'; - -import { keys } from '@/api/queryKeys'; -import { - GeoCategoryPageSchema, - GeoEntryPageSchema, - GeoFileSchema, - GeodataTokenIssueSchema, -} from '@/generated/zod'; -import type { GeoCategoryPage, GeoEntryPage, GeoFile, GeodataTokenIssue } from '@/generated/types'; -import { HttpUtil } from '@/utils'; -import { parseMsg } from '@/utils/zodValidate'; - -const GeoFileListSchema = z.array(GeoFileSchema); -const GeodataTokenIssueListSchema = z.array(GeodataTokenIssueSchema); - -const EMPTY_CATEGORY_PAGE: GeoCategoryPage = { total: 0, items: [] }; -const EMPTY_ENTRY_PAGE: GeoEntryPage = { total: 0, items: [] }; - -export type GeoTokenKind = 'ip' | 'domain'; - -export interface ValidateGeoTokensInput { - tokens: string[]; - kind: GeoTokenKind; -} - -async function fetchGeodataFiles(): Promise { - const msg = await HttpUtil.get('/panel/api/xray/geodata/files', undefined, { silent: true }); - if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata files'); - const validated = parseMsg(msg, GeoFileListSchema, 'xray/geodata/files'); - return Array.isArray(validated.obj) ? validated.obj : []; -} - -async function fetchGeodataCategories(file: string, query: string): Promise { - const msg = await HttpUtil.get( - '/panel/api/xray/geodata/categories', - { file, q: query }, - { silent: true }, - ); - if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories'); - const validated = parseMsg(msg, GeoCategoryPageSchema, 'xray/geodata/categories'); - return validated.obj ?? EMPTY_CATEGORY_PAGE; -} - -async function fetchGeodataEntries( - file: string, - code: string, - query: string, - offset: number, - limit: number, -): Promise { - const msg = await HttpUtil.get( - '/panel/api/xray/geodata/entries', - { file, code, q: query, offset, limit }, - { silent: true }, - ); - if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata entries'); - const validated = parseMsg(msg, GeoEntryPageSchema, 'xray/geodata/entries'); - return validated.obj ?? EMPTY_ENTRY_PAGE; -} - -export function useGeodataFiles(enabled: boolean) { - return useQuery({ - queryKey: keys.xray.geodata.files(), - queryFn: fetchGeodataFiles, - enabled, - staleTime: 5 * 60 * 1000, - }); -} - -export function useGeodataCategories(file: string | undefined, query: string, enabled: boolean) { - return useQuery({ - queryKey: keys.xray.geodata.categories(file ?? '', query), - queryFn: () => fetchGeodataCategories(file ?? '', query), - enabled: enabled && !!file, - staleTime: 5 * 60 * 1000, - placeholderData: keepPreviousData, - }); -} - -export function useGeodataEntries( - file: string | undefined, - code: string | undefined, - query: string, - offset: number, - limit: number, - enabled: boolean, -) { - return useQuery({ - queryKey: keys.xray.geodata.entries(file ?? '', code ?? '', query, offset, limit), - queryFn: () => fetchGeodataEntries(file ?? '', code ?? '', query, offset, limit), - enabled: enabled && !!file && !!code, - placeholderData: keepPreviousData, - }); -} - -export function useValidateGeoTokens() { - return useMutation({ - mutationFn: async ({ tokens, kind }) => { - const msg = await HttpUtil.post( - '/panel/api/xray/geodata/validate', - { tokens: tokens.join(','), kind }, - { silent: true }, - ); - if (!msg?.success) throw new Error(msg?.msg || 'Failed to validate geodata tokens'); - const validated = parseMsg(msg, GeodataTokenIssueListSchema, 'xray/geodata/validate'); - return Array.isArray(validated.obj) ? validated.obj : []; - }, - }); -} diff --git a/frontend/src/api/queries/useOutboundTags.ts b/frontend/src/api/queries/useOutboundTags.ts index a50470b..bc49e60 100644 --- a/frontend/src/api/queries/useOutboundTags.ts +++ b/frontend/src/api/queries/useOutboundTags.ts @@ -1,36 +1,58 @@ import { useQuery } from '@tanstack/react-query'; +import { HttpUtil } from '@/utils'; import { keys } from '@/api/queryKeys'; -import { fetchXrayConfig } from '@/hooks/useXraySetting'; + +// The outbound tag directory: the factory template's outbounds and +// routing balancers plus the tags contributed by outbound +// subscriptions. It used to be derived from the xray settings page's +// config payload; since the template became code-fixed (ITEM6 B4) +// the panel serves the same directory from a dedicated read endpoint. +export interface OutboundDirectory { + outbounds: { tag: string; protocol: string }[]; + balancers: string[]; + subscriptionOutboundTags: string[]; +} + +async function fetchOutboundDirectory(): Promise { + const msg = await HttpUtil.get('/panel/api/server/outboundDirectory', undefined, { + silent: true, + }); + if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbound directory'); + const obj = (msg.obj ?? {}) as Partial; + return { + outbounds: Array.isArray(obj.outbounds) ? obj.outbounds : [], + balancers: Array.isArray(obj.balancers) ? obj.balancers : [], + subscriptionOutboundTags: Array.isArray(obj.subscriptionOutboundTags) + ? obj.subscriptionOutboundTags + : [], + }; +} // Available outbound (and balancer-eligible) tags the user can route an mtproto -// inbound's Telegram traffic to. Shares the cached xray config query so opening -// the inbound form costs no extra request when the Xray page was already +// inbound's Telegram traffic to. Shares one cached directory query so opening +// the inbound form costs no extra request when another picker already // visited; `select` derives just the tag list without disturbing other readers. export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) { const excludeBlackhole = opts?.excludeBlackhole ?? false; return useQuery({ - queryKey: keys.xray.config(), - queryFn: fetchXrayConfig, + queryKey: keys.server.outboundDirectory(), + queryFn: fetchOutboundDirectory, staleTime: Infinity, select: (data): string[] => { const tags = new Set(); - for (const o of data?.xraySetting?.outbounds ?? []) { - const ob = o as { tag?: string; protocol?: string } | null; + for (const ob of data.outbounds) { if (!ob?.tag) continue; if (excludeBlackhole && ob.protocol === 'blackhole') continue; tags.add(ob.tag); } - for (const t of data?.subscriptionOutboundTags ?? []) { + for (const t of data.subscriptionOutboundTags) { if (t) tags.add(t); } // Balancers are valid routing targets too — injectMtprotoEgress emits a // balancerTag rule when the chosen tag names a balancer. - const balancers = ( - data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined - )?.balancers; - for (const b of balancers ?? []) { - if (b?.tag) tags.add(b.tag); + for (const tag of data.balancers) { + if (tag) tags.add(tag); } return [...tags]; }, @@ -48,26 +70,22 @@ export interface OutboundTagGroups { export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) { const excludeBlackhole = opts?.excludeBlackhole ?? false; return useQuery({ - queryKey: keys.xray.config(), - queryFn: fetchXrayConfig, + queryKey: keys.server.outboundDirectory(), + queryFn: fetchOutboundDirectory, staleTime: Infinity, select: (data): OutboundTagGroups => { const outbounds = new Set(); - for (const o of data?.xraySetting?.outbounds ?? []) { - const ob = o as { tag?: string; protocol?: string } | null; + for (const ob of data.outbounds) { if (!ob?.tag) continue; if (excludeBlackhole && ob.protocol === 'blackhole') continue; outbounds.add(ob.tag); } - for (const t of data?.subscriptionOutboundTags ?? []) { + for (const t of data.subscriptionOutboundTags) { if (t) outbounds.add(t); } const balancers: string[] = []; - const bal = ( - data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined - )?.balancers; - for (const b of bal ?? []) { - if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag); + for (const tag of data.balancers) { + if (tag && !outbounds.has(tag)) balancers.push(tag); } return { outbounds: [...outbounds], balancers }; }, diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index ef3ba98..bf0d57e 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -2,6 +2,7 @@ export const keys = { server: { status: () => ['server', 'status'] as const, fail2banStatus: () => ['server', 'fail2banStatus'] as const, + outboundDirectory: () => ['server', 'outboundDirectory'] as const, }, nodes: { root: () => ['nodes'] as const, @@ -37,17 +38,4 @@ export const keys = { activeInbounds: () => ['clients', 'activeInbounds'] as const, lastOnline: () => ['clients', 'lastOnline'] as const, }, - xray: { - root: () => ['xray'] as const, - config: () => ['xray', 'config'] as const, - outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const, - geodata: { - root: () => ['xray', 'geodata'] as const, - files: () => ['xray', 'geodata', 'files'] as const, - categories: (file: string, query: string) => - ['xray', 'geodata', 'categories', file, query] as const, - entries: (file: string, code: string, query: string, offset: number, limit: number) => - ['xray', 'geodata', 'entries', file, code, query, offset, limit] as const, - }, - }, } as const; diff --git a/frontend/src/api/websocketBridge.ts b/frontend/src/api/websocketBridge.ts index 9394398..ce3ade8 100644 --- a/frontend/src/api/websocketBridge.ts +++ b/frontend/src/api/websocketBridge.ts @@ -30,11 +30,6 @@ export function useWebSocketBridge() { }, 200); }; - const onOutbounds: Handler = (payload) => { - if (!Array.isArray(payload)) return; - queryClient.setQueryData(keys.xray.outboundsTraffic(), payload); - }; - const onNodes: Handler = (payload) => { if (!Array.isArray(payload)) return; queryClient.setQueryData(keys.nodes.list(), payload); @@ -46,14 +41,12 @@ export function useWebSocketBridge() { }; client.on('invalidate', onInvalidate); - client.on('outbounds', onOutbounds); client.on('nodes', onNodes); client.on('inbounds', onInbounds); client.connect(); return () => { client.off('invalidate', onInvalidate); - client.off('outbounds', onOutbounds); client.off('nodes', onNodes); client.off('inbounds', onInbounds); if (invalidateTimer != null) { diff --git a/frontend/src/components/geodata/GeoBrowserModal.css b/frontend/src/components/geodata/GeoBrowserModal.css deleted file mode 100644 index fd7782a..0000000 --- a/frontend/src/components/geodata/GeoBrowserModal.css +++ /dev/null @@ -1,221 +0,0 @@ -.geo-browser-modal .geo-toolbar { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - margin-bottom: 12px; -} - -.geo-browser-modal .geo-toolbar .ant-input-search { - flex: 1; - min-width: 180px; -} - -.geo-browser-modal .geo-meta { - margin-inline-start: auto; - font-size: 12px; - color: var(--ant-color-text-tertiary); - font-variant-numeric: tabular-nums; -} - -.geo-browser-modal .geo-columns { - display: grid; - grid-template-columns: minmax(240px, 340px) minmax(0, 1fr); - gap: 12px; - height: 440px; -} - -/* Both panes are the same fixed height, and the pager sits on the pane's floor - rather than under the last row, so neither the dialog nor its controls move - as the user steps between categories with wildly different rule counts. */ -.geo-browser-modal .geo-panel { - height: 100%; - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; - border: 1px solid var(--ant-color-border-secondary); - border-radius: 8px; -} - -.geo-browser-modal .geo-panel .ant-table-wrapper, -.geo-browser-modal .geo-panel .ant-spin-nested-loading, -.geo-browser-modal .geo-panel .ant-spin-container { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - width: 100%; -} - -.geo-browser-modal .geo-panel .ant-table { - flex: 1; - min-height: 0; -} - -/* The rules table fills whatever is left between the header and the pager - instead of carrying a hardcoded scroll height, so there is no dead strip - above the pager and short categories do not scroll needlessly. */ -.geo-browser-modal .geo-preview-body { - flex: 1; - min-height: 0; - overflow-y: auto; -} - -.geo-browser-modal .geo-pager { - margin-top: auto; - display: flex; - align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - padding: 6px 12px; - border-top: 1px solid var(--ant-color-border-secondary); - font-variant-numeric: tabular-nums; -} - -.geo-browser-modal .geo-pager .ant-pagination-total-text { - font-size: 12px; - color: var(--ant-color-text-tertiary); -} - -.geo-browser-modal .geo-categories .ant-table-row { - cursor: pointer; -} - -.geo-browser-modal .geo-row-active > td { - background: var(--ant-color-primary-bg); -} - -.geo-browser-modal .geo-category { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.geo-browser-modal .geo-code, -.geo-browser-modal .geo-entry-value, -.geo-browser-modal .geo-preview-title { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.geo-browser-modal .geo-attrs .ant-tag { - font-size: 10px; - line-height: 16px; - margin-inline-end: 4px; - padding-inline: 4px; -} - -.geo-browser-modal .geo-count { - font-variant-numeric: tabular-nums; - color: var(--ant-color-text-tertiary); - font-size: 12px; -} - -.geo-browser-modal .geo-preview-head { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - border-bottom: 1px solid var(--ant-color-border-secondary); -} - -/* The title is the part that gives way: without min-width it refuses to - shrink, and the filter is pushed onto a second line instead of the long - category name being clipped. */ -.geo-browser-modal .geo-preview-title { - flex: 0 1 auto; - min-width: 0; -} - -.geo-browser-modal .geo-preview-head .ant-typography { - flex: none; - white-space: nowrap; -} - -.geo-browser-modal .geo-entry-filter { - flex: none; - width: 200px; - margin-inline-start: auto; -} - -@media (max-width: 520px) { - .geo-browser-modal .geo-preview-head { - flex-wrap: wrap; - } - - .geo-browser-modal .geo-entry-filter { - width: 100%; - } -} - -.geo-browser-modal .geo-kind { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.geo-browser-modal .geo-kind-full { - color: var(--ant-color-success); -} - -.geo-browser-modal .geo-kind-keyword { - color: var(--ant-color-warning); -} - -.geo-browser-modal .geo-kind-regexp { - color: var(--ant-color-primary); -} - -.geo-browser-modal .geo-placeholder { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 40px 20px; - text-align: center; -} - -.geo-browser-modal .geo-footer { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--ant-color-border-secondary); -} - -.geo-browser-modal .geo-chips { - flex: 1; - max-height: 76px; - overflow-y: auto; -} - -.geo-browser-modal .geo-selected-count { - font-size: 12px; - color: var(--ant-color-text-tertiary); - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -@media (max-width: 720px) { - .geo-browser-modal .geo-columns { - grid-template-columns: minmax(0, 1fr); - height: auto; - } - - .geo-browser-modal .geo-panel { - height: 320px; - } -} - -.geo-unknown-hint { - display: block; - margin-top: 4px; - font-size: 12px; -} diff --git a/frontend/src/components/geodata/GeoBrowserModal.stories.tsx b/frontend/src/components/geodata/GeoBrowserModal.stories.tsx deleted file mode 100644 index b4f9e56..0000000 --- a/frontend/src/components/geodata/GeoBrowserModal.stories.tsx +++ /dev/null @@ -1,864 +0,0 @@ -import { useEffect, useState, type ReactNode } from 'react'; -import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { expect, within } from 'storybook/test'; -import { Button, Space, Typography } from 'antd'; - -import type { GeoCategory, GeoEntry, GeoFile } from '@/generated/types'; - -import GeoBrowserModal, { type GeoBrowserModalProps } from './GeoBrowserModal'; - -type GeoResponder = (query: URLSearchParams) => unknown; -type GeoRoutes = Record; - -const realFetch = window.fetch.bind(window); -let activeRoutes: GeoRoutes = {}; - -function requestUrl(input: RequestInfo | URL): URL { - if (typeof input === 'string') return new URL(input, window.location.origin); - if (input instanceof URL) return input; - return new URL(input.url, window.location.origin); -} - -function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const url = requestUrl(input); - const responder = activeRoutes[url.pathname]; - if (!responder) return realFetch(input, init); - const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams) }); - return Promise.resolve( - new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }), - ); -} - -function activate(routes: GeoRoutes): void { - activeRoutes = routes; - window.fetch = geoFetch; -} - -function deactivate(routes: GeoRoutes): void { - if (activeRoutes === routes) activeRoutes = {}; -} - -function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) { - const [client] = useState(() => { - activate(routes); - return new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - }); - useEffect(() => { - activate(routes); - return () => deactivate(routes); - }, [routes]); - return {children}; -} - -const domain = (value: string): GeoEntry => ({ kind: 'domain', value }); -const full = (value: string): GeoEntry => ({ kind: 'full', value }); -const keyword = (value: string): GeoEntry => ({ kind: 'keyword', value }); -const regexp = (value: string): GeoEntry => ({ kind: 'regexp', value }); -const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value }); - -const cross = (names: string[], suffixes: string[]): GeoEntry[] => - names.flatMap((name) => suffixes.map((suffix) => domain(`${name}.${suffix}`))); - -const CC_TLDS = [ - 'ae', - 'al', - 'am', - 'at', - 'az', - 'ba', - 'be', - 'bg', - 'bi', - 'bj', - 'ca', - 'cat', - 'cd', - 'cf', - 'cg', - 'ch', - 'ci', - 'cl', - 'cm', - 'co.id', - 'co.il', - 'co.in', - 'co.jp', - 'co.ke', - 'co.kr', - 'co.ma', - 'co.nz', - 'co.th', - 'co.uk', - 'co.uz', - 'co.ve', - 'co.za', - 'com.ar', - 'com.au', - 'com.bd', - 'com.br', - 'com.co', - 'com.cu', - 'com.eg', - 'com.gt', - 'com.hk', - 'com.mx', - 'com.my', - 'com.ng', - 'com.pe', - 'com.ph', - 'com.pk', - 'com.sa', - 'com.sg', - 'com.tr', - 'com.tw', - 'com.ua', - 'com.uy', - 'com.vn', - 'cz', - 'de', - 'dj', - 'dk', - 'dz', - 'ee', - 'es', - 'fi', - 'fr', - 'ga', - 'ge', - 'gl', - 'gm', - 'gr', - 'hn', - 'hr', - 'ht', - 'hu', - 'ie', - 'iq', - 'is', - 'it', - 'je', - 'jo', - 'kg', - 'kz', - 'la', - 'li', - 'lk', - 'lt', - 'lu', - 'lv', - 'ly', - 'md', - 'me', - 'mg', - 'mk', - 'ml', - 'mn', - 'mu', - 'mv', - 'mw', - 'ne', - 'nl', - 'no', - 'nu', - 'pl', - 'pt', - 'ro', - 'rs', - 'ru', - 'rw', - 'se', - 'sh', - 'si', - 'sk', - 'sm', - 'sn', - 'so', - 'sr', - 'st', - 'td', - 'tg', - 'tk', - 'tl', - 'tm', - 'tn', - 'to', - 'tt', - 'vg', - 'vu', - 'ws', -]; - -const AD_HOSTS = [ - 'adform', - 'adnxs', - 'adroll', - 'adsrvr', - 'amplitude', - 'appsflyer', - 'bluekai', - 'branch', - 'casalemedia', - 'criteo', - 'flurry', - 'moatads', - 'mopub', - 'openx', - 'outbrain', - 'pubmatic', - 'quantserve', - 'rubiconproject', - 'scorecardresearch', - 'sharethrough', - 'smartadserver', - 'taboola', - 'teads', - 'yieldmo', - 'zemanta', -]; - -const CN_BRANDS = [ - '58', - 'alibaba', - 'alipay', - 'aliyun', - 'baidu', - 'bilibili', - 'cnblogs', - 'csdn', - 'ctrip', - 'douban', - 'gitee', - 'huawei', - 'iqiyi', - 'jd', - 'kuaishou', - 'meituan', - 'netease', - 'pinduoduo', - 'qq', - 'sina', - 'sohu', - 'taobao', - 'tencent', - 'tmall', - 'toutiao', - 'weibo', - 'xiaomi', - 'youku', - 'zhihu', -]; - -const SITE_ENTRIES: Record = { - amazon: [ - domain('amazon.com'), - domain('amazonaws.com'), - domain('media-amazon.com'), - domain('ssl-images-amazon.com'), - domain('primevideo.com'), - domain('awsstatic.com'), - domain('cloudfront.net'), - full('www.amazon.co.jp'), - ], - apple: [ - domain('apple.com'), - domain('icloud.com'), - domain('cdn-apple.com'), - domain('mzstatic.com'), - domain('apple-cloudkit.com'), - domain('itunes.com'), - domain('me.com'), - domain('appstore.com'), - ], - 'category-ads': [ - domain('adcolony.com'), - domain('applovin.com'), - domain('chartboost.com'), - domain('inmobi.com'), - domain('unityads.unity3d.com'), - keyword('banner-ad'), - ], - 'category-ads-all': [ - domain('doubleclick.net'), - domain('googleadservices.com'), - domain('googlesyndication.com'), - domain('adservice.google.com'), - full('ads.yahoo.com'), - keyword('adservice'), - keyword('advertising'), - regexp('^ad[0-9]{1,3}\\.'), - ...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']), - ], - cloudflare: [ - domain('cloudflare.com'), - domain('cloudflare-dns.com'), - domain('cloudflareinsights.com'), - domain('workers.dev'), - domain('pages.dev'), - domain('cf-ipfs.com'), - ], - cn: [full('www.gov.cn'), keyword('chinanet'), ...cross(CN_BRANDS, ['com', 'cn', 'com.cn'])], - discord: [ - domain('discord.com'), - domain('discord.gg'), - domain('discordapp.com'), - domain('discordapp.net'), - domain('discord.media'), - ], - facebook: [ - domain('facebook.com'), - domain('fbcdn.net'), - domain('fb.com'), - domain('messenger.com'), - domain('fbsbx.com'), - domain('facebook.net'), - full('m.facebook.com'), - ], - 'geolocation-!cn': [ - keyword('proxy'), - regexp('.*\\.onion$'), - domain('wikipedia.org'), - domain('bbc.com'), - domain('nytimes.com'), - domain('reuters.com'), - domain('medium.com'), - domain('reddit.com'), - ], - 'geolocation-cn': [ - domain('gov.cn'), - domain('edu.cn'), - domain('org.cn'), - domain('net.cn'), - ...cross(CN_BRANDS.slice(0, 18), ['cn']), - ], - github: [ - domain('github.com'), - domain('githubusercontent.com'), - domain('githubassets.com'), - domain('github.io'), - domain('ghcr.io'), - domain('git.io'), - ], - google: [ - domain('google.com'), - domain('googleapis.com'), - domain('gstatic.com'), - domain('googleusercontent.com'), - domain('google-analytics.com'), - domain('googletagmanager.com'), - domain('ggpht.com'), - domain('withgoogle.com'), - domain('android.com'), - domain('chromium.org'), - domain('abc.xyz'), - full('dl.google.com'), - ...CC_TLDS.map((tld) => domain(`google.${tld}`)), - ], - instagram: [domain('instagram.com'), domain('cdninstagram.com'), domain('ig.me')], - microsoft: [ - domain('microsoft.com'), - domain('live.com'), - domain('office.com'), - domain('office365.com'), - domain('windows.net'), - domain('windowsupdate.com'), - domain('msn.com'), - domain('azure.com'), - domain('sharepoint.com'), - domain('skype.com'), - domain('bing.com'), - ], - netflix: [ - domain('netflix.com'), - domain('netflix.net'), - domain('nflximg.com'), - domain('nflximg.net'), - domain('nflxvideo.net'), - domain('nflxso.net'), - domain('nflxext.com'), - full('fast.com'), - ], - openai: [ - domain('openai.com'), - domain('chatgpt.com'), - domain('oaistatic.com'), - domain('oaiusercontent.com'), - domain('sora.com'), - ], - spotify: [ - domain('spotify.com'), - domain('scdn.co'), - domain('spotifycdn.com'), - domain('spoti.fi'), - domain('spotifycdn.net'), - ], - steam: [ - domain('steampowered.com'), - domain('steamcommunity.com'), - domain('steamstatic.com'), - domain('steamcontent.com'), - domain('valvesoftware.com'), - ], - telegram: [ - domain('telegram.org'), - domain('telegram.me'), - domain('t.me'), - domain('telesco.pe'), - domain('tdesktop.com'), - domain('telegra.ph'), - domain('cdn-telegram.org'), - full('comments.app'), - keyword('telegram'), - ], - tiktok: [ - domain('tiktok.com'), - domain('tiktokcdn.com'), - domain('tiktokv.com'), - domain('byteoversea.com'), - domain('ibytedtos.com'), - domain('musical.ly'), - ], - twitch: [domain('twitch.tv'), domain('ttvnw.net'), domain('jtvnw.net'), domain('twitchcdn.net')], - twitter: [ - domain('twitter.com'), - domain('x.com'), - domain('t.co'), - domain('twimg.com'), - domain('periscope.tv'), - ], - whatsapp: [domain('whatsapp.com'), domain('whatsapp.net'), domain('wa.me')], - youtube: [ - domain('youtube.com'), - domain('youtu.be'), - domain('ytimg.com'), - domain('googlevideo.com'), - domain('youtube-nocookie.com'), - domain('yt.be'), - ], -}; - -const SITE_ATTRIBUTES: Record = { - amazon: ['ads'], - apple: ['cn'], - facebook: ['ads'], - google: ['ads', 'cn'], - instagram: ['ads'], - microsoft: ['cn'], - tiktok: ['ads', 'cn'], - twitter: ['ads'], - youtube: ['ads'], -}; - -const CN_BLOCKS = [ - '1.0.1.0/24', - '1.0.2.0/23', - '1.0.8.0/21', - '14.0.12.0/22', - '27.0.128.0/21', - '36.0.0.0/22', - '39.0.0.0/24', - '42.0.0.0/22', - '58.14.0.0/15', - '59.32.0.0/11', - '61.128.0.0/10', - '101.16.0.0/12', - '103.1.8.0/22', - '106.0.0.0/10', - '110.6.0.0/15', - '111.0.0.0/10', - '112.0.0.0/10', - '113.0.0.0/9', - '114.28.0.0/16', - '116.0.0.0/9', - '117.8.0.0/13', - '118.24.0.0/15', - '119.0.0.0/9', - '120.0.0.0/10', - '121.0.0.0/8', - '124.0.0.0/8', - '125.32.0.0/11', - '139.196.0.0/14', - '140.75.0.0/16', - '175.0.0.0/12', - '180.76.0.0/16', - '182.16.0.0/12', - '183.0.0.0/10', - '202.0.0.0/12', - '203.0.0.0/12', - '210.0.0.0/12', - '211.64.0.0/11', - '218.0.0.0/9', - '219.72.0.0/14', - '220.112.0.0/12', - '221.0.0.0/9', - '222.16.0.0/12', - '2001:250::/35', - '2400:3200::/32', - '2408:8000::/20', -]; - -const CN_EXTRA_BLOCKS = Array.from( - { length: 96 }, - (_, index) => `${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`, -); - -const IP_ENTRIES: Record = { - cloudflare: [ - '103.21.244.0/22', - '103.22.200.0/22', - '103.31.4.0/22', - '104.16.0.0/13', - '104.24.0.0/14', - '108.162.192.0/18', - '131.0.72.0/22', - '141.101.64.0/18', - '162.158.0.0/15', - '172.64.0.0/13', - '173.245.48.0/20', - '188.114.96.0/20', - '190.93.240.0/20', - '197.234.240.0/22', - '198.41.128.0/17', - '2400:cb00::/32', - '2606:4700::/32', - ].map(cidr), - cn: [...CN_BLOCKS, ...CN_EXTRA_BLOCKS].map(cidr), - facebook: [ - '31.13.24.0/21', - '31.13.64.0/18', - '66.220.144.0/20', - '69.63.176.0/20', - '69.171.224.0/19', - '157.240.0.0/16', - '179.60.192.0/22', - '185.60.216.0/22', - '2a03:2880::/32', - ].map(cidr), - google: [ - '8.8.4.0/24', - '8.8.8.0/24', - '34.64.0.0/10', - '35.184.0.0/13', - '64.233.160.0/19', - '66.102.0.0/20', - '72.14.192.0/18', - '74.125.0.0/16', - '108.177.8.0/21', - '142.250.0.0/15', - '172.217.0.0/16', - '216.58.192.0/19', - '2404:6800::/32', - '2607:f8b0::/32', - ].map(cidr), - ir: [ - '2.144.0.0/14', - '5.22.0.0/17', - '31.2.128.0/17', - '37.32.0.0/19', - '46.32.0.0/19', - '78.38.0.0/15', - '80.191.0.0/16', - '85.15.0.0/18', - '91.98.0.0/15', - '178.22.72.0/21', - '185.8.172.0/22', - '188.34.0.0/17', - '217.218.0.0/15', - ].map(cidr), - netflix: [ - '23.246.0.0/18', - '37.77.184.0/21', - '45.57.0.0/17', - '64.120.128.0/17', - '66.197.128.0/17', - '108.175.32.0/20', - '185.2.220.0/22', - '192.173.64.0/18', - '198.38.96.0/19', - '198.45.48.0/20', - ].map(cidr), - private: [ - '0.0.0.0/8', - '10.0.0.0/8', - '100.64.0.0/10', - '127.0.0.0/8', - '169.254.0.0/16', - '172.16.0.0/12', - '192.0.0.0/24', - '192.0.2.0/24', - '192.168.0.0/16', - '198.18.0.0/15', - '198.51.100.0/24', - '203.0.113.0/24', - '224.0.0.0/4', - '240.0.0.0/4', - '255.255.255.255/32', - '::1/128', - 'fc00::/7', - 'fe80::/10', - ].map(cidr), - ru: [ - '2.60.0.0/14', - '5.8.0.0/19', - '31.6.0.0/17', - '37.9.0.0/19', - '46.16.0.0/21', - '62.76.0.0/18', - '77.37.128.0/17', - '78.24.216.0/21', - '79.104.0.0/15', - '80.64.128.0/19', - '81.16.96.0/19', - '82.140.128.0/18', - '85.113.0.0/16', - '87.226.0.0/16', - '91.77.0.0/16', - '93.157.0.0/17', - '94.19.0.0/16', - '95.24.0.0/13', - '178.176.0.0/13', - '188.128.0.0/13', - '213.87.0.0/16', - '217.66.152.0/21', - '2a00:1148::/32', - ].map(cidr), - telegram: [ - '91.108.4.0/22', - '91.108.8.0/22', - '91.108.12.0/22', - '91.108.16.0/22', - '91.108.20.0/22', - '91.108.56.0/22', - '149.154.160.0/20', - '2001:67c:4e8::/48', - '2001:b28:f23d::/48', - '2001:b28:f23f::/48', - ].map(cidr), - us: [ - '3.0.0.0/9', - '12.0.0.0/8', - '23.192.0.0/11', - '34.192.0.0/10', - '50.16.0.0/14', - '52.0.0.0/10', - '63.64.0.0/11', - '65.0.0.0/10', - '68.32.0.0/11', - '71.0.0.0/11', - '96.0.0.0/9', - '128.0.0.0/10', - '199.0.0.0/12', - '208.64.0.0/12', - '2600:1f00::/24', - ].map(cidr), -}; - -function categoriesOf( - entries: Record, - attributes: Record = {}, -): GeoCategory[] { - return Object.keys(entries) - .sort() - .map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] })); -} - -const SITE_CATEGORIES = categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES); -const IP_CATEGORIES = categoriesOf(IP_ENTRIES); - -const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12); - -const GEOSITE_FILE: GeoFile = { - name: 'geosite.dat', - kind: 'site', - size: 4_812_544, - modifiedAt: UPDATED_AT, - categories: SITE_CATEGORIES.length, -}; - -const GEOIP_FILE: GeoFile = { - name: 'geoip.dat', - kind: 'ip', - size: 8_694_272, - modifiedAt: UPDATED_AT, - categories: IP_CATEGORIES.length, -}; - -const DAMAGED_FILE: GeoFile = { - name: 'geosite-custom.dat', - kind: 'site', - size: 262_144, - modifiedAt: Date.UTC(2026, 5, 2, 19, 45), - categories: 0, - error: 'proto: cannot parse invalid wire-format data', -}; - -const OVERSIZED_FILE: GeoFile = { - name: 'geoip-full.dat', - kind: 'ip', - size: 96_468_992, - modifiedAt: Date.UTC(2026, 6, 20, 8, 5), - categories: 0, - error: 'geodata file is too large to browse', -}; - -const DATASETS: Record }> = - { - 'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES }, - 'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES }, - }; - -function routesFor(files: GeoFile[]): GeoRoutes { - return { - '/panel/api/xray/geodata/files': () => files, - '/panel/api/xray/geodata/categories': (query) => { - const dataset = DATASETS[query.get('file') ?? '']; - const needle = (query.get('q') ?? '').trim().toLowerCase(); - const items = (dataset?.categories ?? []).filter((category) => - category.code.includes(needle), - ); - return { total: items.length, items }; - }, - '/panel/api/xray/geodata/entries': (query) => { - const dataset = DATASETS[query.get('file') ?? '']; - const needle = (query.get('q') ?? '').trim().toLowerCase(); - const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) => - entry.value.toLowerCase().includes(needle), - ); - const offset = Number(query.get('offset') ?? 0); - const limit = Number(query.get('limit') ?? 100); - return { total: matched.length, items: matched.slice(offset, offset + limit) }; - }, - }; -} - -function withFiles(files: GeoFile[]): Decorator { - const routes = routesFor(files); - return function GeodataBackend(Story) { - return ( - - - - ); - }; -} - -const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]); - -function BrowserDemo(props: GeoBrowserModalProps) { - const [open, setOpen] = useState(props.open); - const [value, setValue] = useState(props.value); - const [synced, setSynced] = useState({ open: props.open, value: props.value }); - if (synced.open !== props.open || synced.value !== props.value) { - setSynced({ open: props.open, value: props.value }); - setOpen(props.open); - setValue(props.value); - } - return ( - - - - {value || 'no rule yet'} - - { - setValue(next); - setOpen(false); - }} - onClose={() => setOpen(false)} - /> - - ); -} - -const meta = { - title: 'Geodata/GeoBrowserModal', - component: GeoBrowserModal, - tags: ['autodocs'], - parameters: { - layout: 'padded', - a11y: { - config: { - rules: [{ id: 'color-contrast', enabled: false }], - }, - }, - docs: { - description: { - component: - 'Browser for the geosite/geoip `.dat` databases Xray resolves `geosite:` and `geoip:` routing tokens against: pick a database, search its categories, tick the ones a rule needs, and preview the domains or CIDRs inside the highlighted category. Applying merges the ticked categories back into the rule string, keeping hand-typed domains untouched. The stories serve `/panel/api/xray/geodata/*` from an in-memory fixture, so search, paging and selection all work without a panel backend.', - }, - }, - }, - args: { - open: true, - kind: 'site', - value: '', - onApply: () => undefined, - onClose: () => undefined, - }, - argTypes: { - open: { description: 'Whether the modal is visible.' }, - kind: { - description: - 'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.', - control: 'inline-radio', - options: ['site', 'ip'], - }, - value: { - description: - 'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.', - }, - onApply: { description: 'Called with the merged rule string when Apply is pressed.' }, - onClose: { description: 'Called when the modal is dismissed.' }, - }, - render: (args) => , -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const SiteDatabase: Story = { - decorators: [withDatabases], - args: { kind: 'site', value: 'geosite:google, geosite:telegram, ads.example.com' }, -}; - -export const CategoryPreview: Story = { - decorators: [withDatabases], - args: { kind: 'site', value: 'geosite:google' }, - parameters: { - a11y: { - config: { - rules: [ - { id: 'color-contrast', enabled: false }, - { id: 'scrollable-region-focusable', enabled: false }, - ], - }, - }, - }, - play: async ({ canvasElement, userEvent }) => { - const body = within(canvasElement.ownerDocument.body); - await userEvent.type(await body.findByPlaceholderText('Search category'), 'telegram'); - await userEvent.click(await body.findByText('telegram')); - await expect(await body.findByText('t.me')).toBeVisible(); - }, -}; - -export const IpDatabase: Story = { - decorators: [withDatabases], - args: { kind: 'ip', value: 'geoip:private, 10.0.0.0/8' }, -}; - -export const NoDatabases: Story = { - decorators: [withFiles([])], - args: { kind: 'site', value: 'geosite:google' }, -}; - -export const DamagedDatabase: Story = { - decorators: [withFiles([GEOSITE_FILE, DAMAGED_FILE, OVERSIZED_FILE])], - args: { kind: 'site', value: '' }, -}; diff --git a/frontend/src/components/geodata/GeoBrowserModal.tsx b/frontend/src/components/geodata/GeoBrowserModal.tsx deleted file mode 100644 index 31baacb..0000000 --- a/frontend/src/components/geodata/GeoBrowserModal.tsx +++ /dev/null @@ -1,473 +0,0 @@ -import { useCallback, useEffect, useMemo, 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 [known, setKnown] = useState>(() => new Set()); - const [seededFiles, setSeededFiles] = useState>(() => new Set()); - - const filesQuery = useGeodataFiles(open); - const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]); - const activeFile = useMemo( - () => files.find((candidate) => candidate.name === file), - [files, file], - ); - const fileKind: GeoKind = useMemo(() => activeFile?.kind ?? kind, [activeFile, 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]); - - // Opening, picking the default database and seeding the selection are all - // render-time adjustments — an effect would paint the previous state first. - const [wasOpen, setWasOpen] = useState(false); - if (open !== wasOpen) { - setWasOpen(open); - if (open) { - setKnown(new Set()); - setSeededFiles(new Set()); - setCategoryQuery(''); - setEntryQuery(''); - setEntryFilter(''); - setActiveCode(undefined); - setEntryPage(1); - setSelected([]); - } - } else if (open && !file && files.length > 0) { - setFile(preferredFile(files, kind)); - } else if (open && file && categories.length > 0 && !seededFiles.has(file)) { - const tokens = categories.map((category) => tokenFor(file, category.code, fileKind)); - setKnown(new Set([...known, ...tokens])); - setSeededFiles(new Set(seededFiles).add(file)); - const fromValue = selectionFromValue(value, new Set(tokens)); - if (fromValue.length > 0) { - setSelected((previous) => [ - ...previous, - ...fromValue.filter((token) => !previous.includes(token)), - ]); - } - } - - 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, known))} - 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'); -} diff --git a/frontend/src/components/geodata/GeoTokenInput.stories.tsx b/frontend/src/components/geodata/GeoTokenInput.stories.tsx deleted file mode 100644 index 72daf08..0000000 --- a/frontend/src/components/geodata/GeoTokenInput.stories.tsx +++ /dev/null @@ -1,296 +0,0 @@ -import { useEffect, useState, type ReactNode } from 'react'; -import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { expect, within } from 'storybook/test'; -import { Space } from 'antd'; - -import { parseTokens } from '@/lib/xray/geoTokens'; -import type { GeoCategory, GeoEntry, GeoFile, GeodataTokenIssue } from '@/generated/types'; - -import GeoTokenInput, { type GeoTokenInputProps } from './GeoTokenInput'; - -type GeoResponder = (query: URLSearchParams, body: URLSearchParams) => unknown; -type GeoRoutes = Record; - -const realFetch = window.fetch.bind(window); -let activeRoutes: GeoRoutes = {}; - -function requestUrl(input: RequestInfo | URL): URL { - if (typeof input === 'string') return new URL(input, window.location.origin); - if (input instanceof URL) return input; - return new URL(input.url, window.location.origin); -} - -function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const url = requestUrl(input); - const responder = activeRoutes[url.pathname]; - if (!responder) return realFetch(input, init); - const form = new URLSearchParams(typeof init?.body === 'string' ? init.body : ''); - const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams, form) }); - return Promise.resolve( - new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }), - ); -} - -function activate(routes: GeoRoutes): void { - activeRoutes = routes; - window.fetch = geoFetch; -} - -function deactivate(routes: GeoRoutes): void { - if (activeRoutes === routes) activeRoutes = {}; -} - -function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) { - const [client] = useState(() => { - activate(routes); - return new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - }); - useEffect(() => { - activate(routes); - return () => deactivate(routes); - }, [routes]); - return {children}; -} - -const domain = (value: string): GeoEntry => ({ kind: 'domain', value }); -const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value }); - -const SITE_ENTRIES: Record = { - 'category-ads-all': [ - domain('doubleclick.net'), - domain('googleadservices.com'), - domain('googlesyndication.com'), - domain('criteo.com'), - domain('taboola.com'), - domain('outbrain.com'), - ], - cn: [ - domain('baidu.com'), - domain('qq.com'), - domain('taobao.com'), - domain('weibo.com'), - domain('bilibili.com'), - ], - google: [ - domain('google.com'), - domain('googleapis.com'), - domain('gstatic.com'), - domain('googleusercontent.com'), - domain('ggpht.com'), - domain('android.com'), - ], - netflix: [ - domain('netflix.com'), - domain('nflximg.net'), - domain('nflxvideo.net'), - domain('fast.com'), - ], - telegram: [domain('telegram.org'), domain('t.me'), domain('telesco.pe'), domain('telegra.ph')], - youtube: [ - domain('youtube.com'), - domain('youtu.be'), - domain('ytimg.com'), - domain('googlevideo.com'), - ], -}; - -const IP_ENTRIES: Record = { - cloudflare: ['104.16.0.0/13', '172.64.0.0/13', '2606:4700::/32'].map(cidr), - cn: ['1.0.1.0/24', '36.0.0.0/22', '116.0.0.0/9', '2408:8000::/20'].map(cidr), - private: [ - '10.0.0.0/8', - '127.0.0.0/8', - '169.254.0.0/16', - '172.16.0.0/12', - '192.168.0.0/16', - '::1/128', - 'fc00::/7', - 'fe80::/10', - ].map(cidr), - telegram: ['91.108.4.0/22', '149.154.160.0/20', '2001:b28:f23d::/48'].map(cidr), -}; - -const SITE_ATTRIBUTES: Record = { - google: ['ads', 'cn'], - youtube: ['ads'], -}; - -function categoriesOf( - entries: Record, - attributes: Record = {}, -): GeoCategory[] { - return Object.keys(entries) - .sort() - .map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] })); -} - -const DATASETS: Record }> = - { - 'geosite.dat': { - categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES), - entries: SITE_ENTRIES, - }, - 'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES }, - }; - -const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12); - -const FILES: GeoFile[] = [ - { - name: 'geosite.dat', - kind: 'site', - size: 4_812_544, - modifiedAt: UPDATED_AT, - categories: DATASETS['geosite.dat'].categories.length, - }, - { - name: 'geoip.dat', - kind: 'ip', - size: 8_694_272, - modifiedAt: UPDATED_AT, - categories: DATASETS['geoip.dat'].categories.length, - }, -]; - -function referenceOf(token: string, isIP: boolean): { file: string; code: string } | null { - const [prefix, ...rest] = token.split(':'); - const code = (value: string) => value.split('@')[0].toLowerCase(); - if (prefix === 'geosite') return { file: 'geosite.dat', code: code(rest.join(':')) }; - if (prefix === 'geoip') return { file: 'geoip.dat', code: code(rest.join(':')) }; - if (prefix === 'ext') return { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) }; - return isIP && prefix === 'ext-ip' - ? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) } - : null; -} - -function validate(tokens: string[], isIP: boolean): GeodataTokenIssue[] { - const issues: GeodataTokenIssue[] = []; - for (const token of tokens) { - const reference = referenceOf(token, isIP); - if (!reference) continue; - const dataset = DATASETS[reference.file]; - if (!dataset) { - issues.push({ token, reason: 'fileMissing', file: reference.file, code: reference.code }); - continue; - } - if (!dataset.categories.some((category) => category.code === reference.code)) { - issues.push({ token, reason: 'categoryMissing', file: reference.file, code: reference.code }); - } - } - return issues; -} - -const routes: GeoRoutes = { - '/csrf-token': () => 'storybook-csrf-token', - '/panel/api/xray/geodata/files': () => FILES, - '/panel/api/xray/geodata/categories': (query) => { - const dataset = DATASETS[query.get('file') ?? '']; - const needle = (query.get('q') ?? '').trim().toLowerCase(); - const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle)); - return { total: items.length, items }; - }, - '/panel/api/xray/geodata/entries': (query) => { - const dataset = DATASETS[query.get('file') ?? '']; - const needle = (query.get('q') ?? '').trim().toLowerCase(); - const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) => - entry.value.toLowerCase().includes(needle), - ); - const offset = Number(query.get('offset') ?? 0); - const limit = Number(query.get('limit') ?? 100); - return { total: matched.length, items: matched.slice(offset, offset + limit) }; - }, - '/panel/api/xray/geodata/validate': (_query, form) => - validate(parseTokens(form.get('tokens') ?? ''), form.get('kind') === 'ip'), -}; - -const withGeodata: Decorator = function GeodataBackend(Story) { - return ( - - - - ); -}; - -function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) { - const [current, setCurrent] = useState(value); - const [synced, setSynced] = useState(value); - if (synced !== value) { - setSynced(value); - setCurrent(value); - } - return ( - - - - - ); -} - -const meta = { - title: 'Geodata/GeoTokenInput', - component: GeoTokenInput, - tags: ['autodocs'], - parameters: { - layout: 'padded', - a11y: { - config: { - rules: [{ id: 'color-contrast', enabled: false }], - }, - }, - docs: { - description: { - component: - 'Routing rule field for the xray rule editor: a comma separated list of domains/CIDRs and `geosite:` / `geoip:` tokens, with a database button in the addon that opens the geo category browser. Typed tokens are validated against the databases on disk after a short pause, and anything the running core would not resolve is called out under the field. The stories answer `/panel/api/xray/geodata/*` from an in-memory fixture, so validation and the browser both work without a panel backend.', - }, - }, - }, - decorators: [withGeodata], - args: { kind: 'domain' }, - argTypes: { - value: { description: 'Comma separated rule string held by the parent form.' }, - onChange: { - description: 'Called with the full rule string on every edit and on Apply from the browser.', - }, - onBlur: { - description: 'Forwarded to the input; used by React Hook Form to mark the field touched.', - }, - kind: { - description: - 'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.', - control: 'inline-radio', - options: ['domain', 'ip'], - }, - placeholder: { description: 'Placeholder shown while the field is empty.' }, - id: { description: 'Input id, linked to the label rendered by the surrounding form field.' }, - }, - render: (args) => , -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Empty: Story = { - args: { kind: 'domain', value: '', placeholder: 'geosite:google, example.com' }, -}; - -export const DomainTokens: Story = { - args: { kind: 'domain', value: 'geosite:google, google.com' }, -}; - -export const IpTokens: Story = { - args: { kind: 'ip', value: 'geoip:private' }, -}; - -export const UnknownCategory: Story = { - args: { kind: 'domain', value: 'geosite:blabla, geosite:google' }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect( - await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 }), - ).toBeVisible(); - }, -}; diff --git a/frontend/src/components/geodata/GeoTokenInput.tsx b/frontend/src/components/geodata/GeoTokenInput.tsx deleted file mode 100644 index 9d41a07..0000000 --- a/frontend/src/components/geodata/GeoTokenInput.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { Ref } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button, Input, Space, Tooltip, Typography } from 'antd'; -import type { InputRef } from 'antd'; -import { DatabaseOutlined } from '@ant-design/icons'; - -import { useValidateGeoTokens, type GeoTokenKind } from '@/api/queries/useGeodata'; -import { parseTokens } from '@/lib/xray/geoTokens'; -import type { GeodataTokenIssue, GeoKind } from '@/generated/types'; - -import GeoBrowserModal from './GeoBrowserModal'; - -const VALIDATION_DELAY = 600; - -// Each reason needs its own wording: a missing database is fixed under Geodata, -// a missing category by picking another one, and a bad token by editing it. -const REASON_KEYS: Record = { - fileMissing: 'pages.xray.geoBrowser.missingDatabase', - categoryMissing: 'pages.xray.geoBrowser.unknownCategories', - attributeMissing: 'pages.xray.geoBrowser.unknownAttribute', - syntax: 'pages.xray.geoBrowser.invalidToken', - wrongKind: 'pages.xray.geoBrowser.wrongKind', -}; - -export interface GeoTokenInputProps { - value?: string; - onChange?: (value: string) => void; - onBlur?: () => void; - kind: GeoTokenKind; - placeholder?: string; - id?: string; - ref?: Ref; -} - -export default function GeoTokenInput({ - value = '', - onChange, - onBlur, - kind, - placeholder, - id, - ref, -}: GeoTokenInputProps) { - const { t } = useTranslation(); - const [browsing, setBrowsing] = useState(false); - const [issues, setIssues] = useState([]); - const [checkFailed, setCheckFailed] = useState(false); - const validate = useValidateGeoTokens(); - const { mutateAsync } = validate; - - // An empty field has nothing to validate, so it clears during render rather - // than waiting a commit for the effect to catch up. - const isEmpty = parseTokens(value).length === 0; - const [wasEmpty, setWasEmpty] = useState(isEmpty); - if (isEmpty !== wasEmpty) { - setWasEmpty(isEmpty); - if (isEmpty) { - setIssues([]); - setCheckFailed(false); - } - } - - useEffect(() => { - const tokens = parseTokens(value); - if (tokens.length === 0) return; - let cancelled = false; - const timer = setTimeout(() => { - mutateAsync({ tokens, kind }) - .then((found) => { - if (cancelled) return; - setIssues(found); - setCheckFailed(false); - }) - // A rejected check says nothing about the tokens, so the warnings are - // dropped but replaced by a notice — silence here reads as "all valid". - .catch(() => { - if (cancelled) return; - setIssues([]); - setCheckFailed(true); - }); - }, VALIDATION_DELAY); - return () => { - cancelled = true; - clearTimeout(timer); - }; - }, [value, kind, mutateAsync]); - - return ( - <> - - onChange?.(event.target.value)} - onBlur={onBlur} - /> - -
r.key} - pagination={false} - size="small" - locale={{ - emptyText: ( -
- -
{t('noData')}
-
- ), - }} - /> - )} - - setModalOpen(false)} - onConfirm={onConfirm} - /> - setImportOpen(false)} - title={t('pages.xray.importOutbounds')} - okText={t('pages.xray.importOutbounds')} - type="textarea" - json - onConfirm={importOutbounds} - /> - setExportOpen(false)} - title={t('pages.xray.exportOutbounds')} - content={exportContent} - fileName="outbounds.json" - json - /> - - {/* Subscription outbounds (read-only, merged at runtime) */} - {Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && ( - - )} - - - setSubDrawerOpen(false)} - footer={null} - width={isMobile ? '100%' : 640} - destroyOnHidden - > - -
- {editingSubId != null && ( -
- {t('edit')} - {newSub.remark || newSub.url} -
- )} -
- - setNewSub({ ...newSub, remark: e.target.value })} - placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} - /> - - - setNewSub({ ...newSub, url: e.target.value })} - placeholder={t('pages.xray.outboundSub.urlPlaceholder')} - /> - - - setNewSub({ ...newSub, tagPrefix: e.target.value })} - placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} - /> - - - - setIntervalHM(v, intervalMinutes))} - style={{ width: 80 }} - />{' '} - {t('pages.xray.outboundSub.hours')} - setIntervalHM(intervalHours, v))} - style={{ width: 80 }} - />{' '} - {t('pages.xray.outboundSub.minutes')} - -
- {t('pages.xray.outboundSub.intervalHint')} -
-
- - setNewSub({ ...newSub, enabled: v })} - /> - - - setNewSub({ ...newSub, allowPrivate: v })} - /> -
- {t('pages.xray.outboundSub.allowPrivateHint')} -
-
- - setNewSub({ ...newSub, allowInsecure: v })} - /> -
- {t('pages.hosts.hints.allowInsecure')} -
-
- - setNewSub({ ...newSub, prepend: v })} - /> -
- {t('pages.xray.outboundSub.prependHint')} -
-
- - - - {editingSubId != null && } - - {previewData && previewData.length > 0 && ( -
-
- {previewData.length} · {t('pages.xray.Outbounds')} -
-
- {previewData.map((o, i) => ( - - {o?.tag || '—'} - {o?.protocol ? ` · ${o.protocol}` : ''} - - ))} -
-
- )} - -
- -
-
- {t('pages.xray.outboundSub.active')} - - )} -
- {subs.length === 0 ? ( -
{t('pages.xray.outboundSub.empty')}
- ) : ( -
r.id} - pagination={false} - scroll={{ x: true }} - columns={[ - { - title: '', - key: 'order', - width: 56, - render: (_: unknown, r: OutboundSub, index: number) => ( - -
r.key} - pagination={false} - size="small" - /> - - ); -} diff --git a/frontend/src/pages/xray/outbounds/index.ts b/frontend/src/pages/xray/outbounds/index.ts deleted file mode 100644 index 30edc9b..0000000 --- a/frontend/src/pages/xray/outbounds/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as OutboundsTab } from './OutboundsTab'; -export { default as OutboundFormModal } from './OutboundFormModal'; diff --git a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts deleted file mode 100644 index 1c2f0c1..0000000 --- a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { OutboundProtocols as Protocols } from '@/schemas/primitives'; -import type { OutboundTrafficRow } from '@/hooks/useXraySetting'; - -import type { OutboundRow } from './outbounds-tab-types'; - -/** - * Translate a table row's positional index into that outbound's index in the - * full, unfiltered outbounds array. The table hides balancer-loopback outbounds - * but keeps each visible row's original index in `key`, so any handler that - * mutates the outbounds array (or probes an outbound by index) must map the - * positional index back through `key` or it operates on the wrong outbound once - * a hidden loopback precedes it. - */ -export function originalOutboundIndex(rows: OutboundRow[], positionalIndex: number): number { - const row = rows[positionalIndex]; - return row ? row.key : positionalIndex; -} - -export function outboundAddresses(o: OutboundRow): string[] { - const settings = o.settings as Record | undefined; - switch (o.protocol) { - case Protocols.VMess: { - const serverObj = settings?.vnext as Array<{ address: string; port: number }> | undefined; - return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : []; - } - case Protocols.VLESS: - return [`${settings?.address || ''}:${settings?.port || ''}`]; - case Protocols.HTTP: - case Protocols.Socks: - case Protocols.Shadowsocks: - case Protocols.Trojan: { - const serverObj = settings?.servers as Array<{ address: string; port: number }> | undefined; - return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : []; - } - case Protocols.DNS: { - const addr = (settings?.rewriteAddress as string) || (settings?.address as string) || ''; - const port = - (settings?.rewritePort as string | number) || (settings?.port as string | number) || ''; - return addr || port ? [`${addr}:${port}`] : []; - } - case Protocols.Wireguard: - return ((settings?.peers as Array<{ endpoint?: string }>) || []) - .map((p) => p.endpoint || '') - .filter(Boolean); - default: - return []; - } -} - -export function showSecurity(security?: string): boolean { - return security === 'tls' || security === 'reality'; -} - -export function trafficFor( - outboundsTraffic: OutboundTrafficRow[], - o: OutboundRow, -): { up: number; down: number } { - const tr = outboundsTraffic.find((x) => x.tag === o.tag); - return { up: tr?.up || 0, down: tr?.down || 0 }; -} - -export function countryFlag(country?: string): string { - const code = (country || '').trim().toUpperCase(); - if (!/^[A-Z]{2}$/.test(code)) return ''; - return String.fromCodePoint(...[...code].map((ch) => 0x1f1e6 + ch.charCodeAt(0) - 65)); -} - -export function countryName(country?: string, locale?: string): string { - const code = (country || '').trim().toUpperCase(); - if (!/^[A-Z]{2}$/.test(code)) return ''; - try { - return ( - new Intl.DisplayNames(locale ? [locale] : undefined, { type: 'region' }).of(code) || code - ); - } catch { - return code; - } -} diff --git a/frontend/src/pages/xray/outbounds/outbounds-tab-types.ts b/frontend/src/pages/xray/outbounds/outbounds-tab-types.ts deleted file mode 100644 index ecffa6c..0000000 --- a/frontend/src/pages/xray/outbounds/outbounds-tab-types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface OutboundRow { - key: number; - tag?: string; - protocol?: string; - streamSettings?: { network?: string; security?: string }; - settings?: Record; -} diff --git a/frontend/src/pages/xray/outbounds/protocols/blackhole.tsx b/frontend/src/pages/xray/outbounds/protocols/blackhole.tsx deleted file mode 100644 index 7368e1c..0000000 --- a/frontend/src/pages/xray/outbounds/protocols/blackhole.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { Select } from 'antd'; - -import { FormField } from '@/components/form/rhf'; - -export default function BlackholeFields() { - const { t } = useTranslation(); - return ( - - - - - - - - - - - - - - - - ), - }, - { - key: 'key', - label: t('pages.xray.nord.privateKey'), - children: ( -
- - - - - - ), - }, - ]} - /> - ) : ( - <> -
- - {nordData.token && ( - - - - - )} - - - - - -
{t('pages.xray.nord.accessToken')}{nordData.token}
{t('pages.xray.nord.privateKey')}{nordData.private_key}
- - - - {t('pages.xray.warp.settings')} - -
- v ?? undefined }} - onAfterChange={(v) => fetchServers(v as number)} - > - ({ value: c.id, label: c.name })), - ]} - /> - - )} - - {filteredServers.length > 0 && ( - - - - - - - -
- ) : ( - <> - - - - - - - -
{t('pages.xray.pia.account')}{piaData.accountHint || piaData.username}
- - - - {t('pages.xray.warp.settings')} - -
- v ?? undefined }} - onAfterChange={(v) => void fetchServers(v as string)} - > - ({ value: r.id, label: r.name })), - ]} - /> - - )} - - {filteredServers.length > 0 && ( - - - -
- - {licenseError && ( - - )} -
-
- ), - }, - { - key: '2', - label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'), - children: ( -
- Number(v) }} - > - - - -
- ), - }, - ]} - /> - - {t('pages.xray.warp.accountInfo')} -
- - -
- - {hasConfig && ( - <> - - - - - - - - - - - - - - - {warpConfig?.account && ( - <> - - - - - - - - - - - - - - - - - {warpConfig.account.usage != null && ( - - - - - )} - - )} - -
{t('pages.xray.warp.deviceName')}{warpConfig?.name}
{t('pages.xray.warp.deviceModel')}{warpConfig?.model}
{t('pages.xray.warp.deviceEnabled')}{String(warpConfig?.enabled)}
{t('pages.xray.warp.accountType')}{warpConfig.account.account_type}
{t('pages.xray.warp.role')}{warpConfig.account.role}
{t('pages.xray.warp.warpPlusData')}{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}
{t('pages.xray.warp.quota')}{SizeFormatter.sizeFormat(warpConfig.account.quota)}
{t('pages.xray.warp.usage')}{SizeFormatter.sizeFormat(warpConfig.account.usage)}
- - {t('pages.xray.outbound.outboundStatus')} - {warpOutboundIndex >= 0 ? ( - <> - {t('enabled')} - - - ) : ( - <> - {t('disabled')} - - - )} - - )} - - )} - - - - ); -} diff --git a/frontend/src/pages/xray/overrides/index.ts b/frontend/src/pages/xray/overrides/index.ts deleted file mode 100644 index dd8c309..0000000 --- a/frontend/src/pages/xray/overrides/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as WarpModal } from './WarpModal'; -export { default as NordModal } from './NordModal'; -export { default as PiaModal } from './PiaModal'; diff --git a/frontend/src/pages/xray/reference-cleanup.ts b/frontend/src/pages/xray/reference-cleanup.ts deleted file mode 100644 index ea5d6c6..0000000 --- a/frontend/src/pages/xray/reference-cleanup.ts +++ /dev/null @@ -1,245 +0,0 @@ -import type { XraySettingsValue } from '@/hooks/useXraySetting'; -import type { BalancerObject, RuleObject } from '@/schemas/routing'; -import { syncObservatories } from './balancers/balancer-helpers'; - -/** - * Reference cleanup for the Xray-config blob: when an outbound or balancer is - * deleted, routing rules and balancers that point at it must be repaired in the - * same edit, or the saved config breaks the core (a dangling balancerTag stops - * Router.Init; a dangling outboundTag black-holes matched traffic). - * - * Keep/drop a rule by its destination: after the deletion, a rule that still has - * an outboundTag or balancerTag is kept (the dead reference is dropped); a rule - * left with neither is removed, since a destination-less rule black-holes the - * traffic it matches. Deleting an outbound cascades: if it empties a balancer's - * selector, that balancer is removed too, and its rules are repaired the same way. - */ - -export type RuleFate = 'removed' | 'modified'; - -export interface RuleImpact { - index: number; - label: string; - fate: RuleFate; - keeps?: string; -} - -export interface BalancerImpact { - tag: string; - reason: 'selectorEmptied'; -} - -export interface DeletionImpact { - rules: RuleImpact[]; - balancers: BalancerImpact[]; - observatory: boolean; - burst: boolean; -} - -const emptyImpact = (): DeletionImpact => ({ - rules: [], - balancers: [], - observatory: false, - burst: false, -}); - -function ruleList(tt: XraySettingsValue): RuleObject[] { - const r = tt.routing?.rules; - return Array.isArray(r) ? r : []; -} - -function balancerList(tt: XraySettingsValue): BalancerObject[] { - const b = tt.routing?.balancers; - return Array.isArray(b) ? b : []; -} - -function outboundTagAt(tt: XraySettingsValue, index: number): string { - const o = tt.outbounds?.[index]; - return typeof o?.tag === 'string' ? o.tag : ''; -} - -function balancerTagAt(tt: XraySettingsValue, index: number): string { - const b = balancerList(tt)[index]; - return typeof b?.tag === 'string' ? b.tag : ''; -} - -function ruleLabel(rule: RuleObject, index: number): string { - const tag = typeof rule.ruleTag === 'string' ? rule.ruleTag.trim() : ''; - return tag || `#${index + 1}`; -} - -/** Balancers whose selector is left empty once `removedOutbounds` are gone. */ -function balancersEmptiedBy(tt: XraySettingsValue, removedOutbounds: Set): string[] { - if (removedOutbounds.size === 0) return []; - const emptied: string[] = []; - for (const b of balancerList(tt)) { - const tag = typeof b?.tag === 'string' ? b.tag : ''; - if (tag === '') continue; - const selector = Array.isArray(b?.selector) ? b.selector : []; - if (selector.length === 0) continue; - if (selector.every((s) => removedOutbounds.has(s))) emptied.push(tag); - } - return emptied; -} - -/** - * Single source of truth for how a deletion affects one rule, shared by the - * preview (`ruleImpacts`) and the mutation (`applyCleanup`) so the two can never - * disagree. Returns null when the rule is untouched; otherwise `keeps` names the - * surviving destination, or is '' when none remains and the rule must be dropped. - */ -function classifyRule( - rule: RuleObject, - removedOutbounds: Set, - removedBalancers: Set, -): { losesOut: boolean; losesBal: boolean; keeps: string } | null { - const out = typeof rule?.outboundTag === 'string' ? rule.outboundTag : ''; - const bal = typeof rule?.balancerTag === 'string' ? rule.balancerTag : ''; - const losesOut = out !== '' && removedOutbounds.has(out); - const losesBal = bal !== '' && removedBalancers.has(bal); - if (!losesOut && !losesBal) return null; - const keptOut = out !== '' && !losesOut ? out : ''; - const keptBal = bal !== '' && !losesBal ? bal : ''; - return { losesOut, losesBal, keeps: keptOut || keptBal }; -} - -function ruleImpacts( - tt: XraySettingsValue, - removedOutbounds: Set, - removedBalancers: Set, -): RuleImpact[] { - const impacts: RuleImpact[] = []; - ruleList(tt).forEach((rule, index) => { - const verdict = classifyRule(rule, removedOutbounds, removedBalancers); - if (!verdict) return; - impacts.push( - verdict.keeps - ? { index, label: ruleLabel(rule, index), fate: 'modified', keeps: verdict.keeps } - : { index, label: ruleLabel(rule, index), fate: 'removed' }, - ); - }); - return impacts; -} - -function applyCleanup( - tt: XraySettingsValue, - removedOutbounds: Set, - removedBalancers: Set, -): void { - if (tt.routing && Array.isArray(tt.routing.rules)) { - const next: RuleObject[] = []; - for (const rule of tt.routing.rules) { - const verdict = classifyRule(rule, removedOutbounds, removedBalancers); - if (!verdict) { - next.push(rule); - continue; - } - if (verdict.losesOut) delete rule.outboundTag; - if (verdict.losesBal) delete rule.balancerTag; - if (verdict.keeps) next.push(rule); - } - tt.routing.rules = next; - } - - if (tt.routing && Array.isArray(tt.routing.balancers)) { - const survivors: BalancerObject[] = []; - for (const balancer of tt.routing.balancers) { - if (!balancer) continue; - if (removedBalancers.has(balancer.tag)) continue; - if (removedOutbounds.size > 0 && Array.isArray(balancer.selector)) { - balancer.selector = balancer.selector.filter((s) => !removedOutbounds.has(s)); - } - if (typeof balancer.fallbackTag === 'string' && removedOutbounds.has(balancer.fallbackTag)) { - balancer.fallbackTag = ''; - } - survivors.push(balancer); - } - tt.routing.balancers = survivors; - } - - if (removedOutbounds.size > 0 && Array.isArray(tt.outbounds)) { - tt.outbounds = tt.outbounds.filter( - (o) => !(typeof o?.tag === 'string' && removedOutbounds.has(o.tag)), - ); - for (const outbound of tt.outbounds) { - const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } }) - ?.streamSettings?.sockopt; - if ( - sockopt && - typeof sockopt.dialerProxy === 'string' && - removedOutbounds.has(sockopt.dialerProxy) - ) { - delete sockopt.dialerProxy; - } - } - } - - syncObservatories(tt); -} - -function observersRemovedBy( - tt: XraySettingsValue, - removedOutbounds: Set, - removedBalancers: Set, -): { observatory: boolean; burst: boolean } { - const hadObservatory = !!tt.observatory; - const hadBurst = !!tt.burstObservatory; - if (!hadObservatory && !hadBurst) return { observatory: false, burst: false }; - const clone = JSON.parse(JSON.stringify(tt)) as XraySettingsValue; - applyCleanup(clone, removedOutbounds, removedBalancers); - return { - observatory: hadObservatory && !clone.observatory, - burst: hadBurst && !clone.burstObservatory, - }; -} - -export function planBalancerDeletion(tt: XraySettingsValue, index: number): DeletionImpact { - const tag = balancerTagAt(tt, index); - if (!tag) return emptyImpact(); - const removedOutbounds = new Set(); - const removedBalancers = new Set([tag]); - const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers); - return { - rules: ruleImpacts(tt, removedOutbounds, removedBalancers), - balancers: [], - observatory: obs.observatory, - burst: obs.burst, - }; -} - -export function applyBalancerDeletion(tt: XraySettingsValue, index: number): void { - const tag = balancerTagAt(tt, index); - if (!tag) { - if (tt.routing && Array.isArray(tt.routing.balancers)) tt.routing.balancers.splice(index, 1); - syncObservatories(tt); - return; - } - applyCleanup(tt, new Set(), new Set([tag])); -} - -export function planOutboundDeletion(tt: XraySettingsValue, index: number): DeletionImpact { - const tag = outboundTagAt(tt, index); - if (!tag) return emptyImpact(); - const removedOutbounds = new Set([tag]); - const cascaded = balancersEmptiedBy(tt, removedOutbounds); - const removedBalancers = new Set(cascaded); - const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers); - return { - rules: ruleImpacts(tt, removedOutbounds, removedBalancers), - balancers: cascaded.map((bTag) => ({ tag: bTag, reason: 'selectorEmptied' as const })), - observatory: obs.observatory, - burst: obs.burst, - }; -} - -export function applyOutboundDeletion(tt: XraySettingsValue, index: number): void { - const tag = outboundTagAt(tt, index); - if (!tag) { - if (Array.isArray(tt.outbounds)) tt.outbounds.splice(index, 1); - syncObservatories(tt); - return; - } - const removedOutbounds = new Set([tag]); - const removedBalancers = new Set(balancersEmptiedBy(tt, removedOutbounds)); - applyCleanup(tt, removedOutbounds, removedBalancers); -} diff --git a/frontend/src/pages/xray/routing/CriterionRow.tsx b/frontend/src/pages/xray/routing/CriterionRow.tsx deleted file mode 100644 index a167af1..0000000 --- a/frontend/src/pages/xray/routing/CriterionRow.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Tooltip } from 'antd'; - -import { csv } from './helpers'; - -export default function CriterionRow({ - label, - value, - values, - title, -}: { - label: string; - value?: string; - values?: string[]; - title: string; -}) { - const parts = values ?? csv(value); - if (parts.length === 0) return null; - return ( - - - {label} - {parts[0]} - {parts.length > 1 && +{parts.length - 1}} - - - ); -} diff --git a/frontend/src/pages/xray/routing/RoutingBasic.tsx b/frontend/src/pages/xray/routing/RoutingBasic.tsx deleted file mode 100644 index e8bba52..0000000 --- a/frontend/src/pages/xray/routing/RoutingBasic.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Alert, Select, Switch } from 'antd'; - -import { SettingListItem } from '@/components/ui'; -import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting'; -import { - BITTORRENT_PROTOCOLS, - BLOCK_DOMAINS_OPTIONS, - DOMAINS_OPTIONS, - IPS_OPTIONS, - SERVICES_OPTIONS, - directSettings, - ipv4Settings, -} from '../basics/constants'; -import { - getDefaultOutboundTag, - ruleGetter, - ruleSetter, - setDefaultOutboundTag, - syncOutbound, -} from '../basics/helpers'; - -interface RoutingBasicProps { - templateSettings: XraySettingsValue | null; - setTemplateSettings: SetTemplate; -} - -export default function RoutingBasic({ templateSettings, setTemplateSettings }: RoutingBasicProps) { - const { t } = useTranslation(); - - const mutate = useCallback( - (mutator: (next: XraySettingsValue) => void) => { - setTemplateSettings((prev) => { - if (!prev) return prev; - const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue; - mutator(clone); - return clone; - }); - }, - [setTemplateSettings], - ); - - const blockedIPs = ruleGetter(templateSettings, 'blocked', 'ip'); - const blockedDomains = ruleGetter(templateSettings, 'blocked', 'domain'); - const blockedProtocols = ruleGetter(templateSettings, 'blocked', 'protocol'); - const directIPs = ruleGetter(templateSettings, 'direct', 'ip'); - const directDomains = ruleGetter(templateSettings, 'direct', 'domain'); - const ipv4Domains = ruleGetter(templateSettings, 'IPv4', 'domain'); - - const torrentActive = BITTORRENT_PROTOCOLS.every((p) => blockedProtocols.includes(p)); - const defaultOutboundTag = getDefaultOutboundTag(templateSettings); - const defaultOutboundOptions = useMemo(() => { - const tags = new Set(['direct', 'blocked']); - for (const o of templateSettings?.outbounds ?? []) { - if (o?.tag) tags.add(o.tag); - } - return [...tags].map((value) => ({ label: value, value })); - }, [templateSettings?.outbounds]); - - return ( - <> - - - mutate((tt) => setDefaultOutboundTag(tt, tag))} - /> - } - /> - - - mutate((tt) => { - const next = checked - ? [...blockedProtocols, ...BITTORRENT_PROTOCOLS] - : blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d)); - ruleSetter(tt, 'blocked', 'protocol', next); - }) - } - /> - } - /> - - mutate((tt) => ruleSetter(tt, 'blocked', 'ip', v))} - /> - } - /> - - mutate((tt) => ruleSetter(tt, 'blocked', 'domain', v))} - /> - } - /> - - - - - mutate((tt) => { - ruleSetter(tt, 'direct', 'ip', v); - syncOutbound(tt, 'direct', directSettings); - }) - } - /> - } - /> - - - mutate((tt) => { - ruleSetter(tt, 'direct', 'domain', v); - syncOutbound(tt, 'direct', directSettings); - }) - } - /> - } - /> - - - mutate((tt) => { - ruleSetter(tt, 'IPv4', 'domain', v); - syncOutbound(tt, 'IPv4', ipv4Settings); - }) - } - /> - } - /> - - ); -} diff --git a/frontend/src/pages/xray/routing/RoutingTab.css b/frontend/src/pages/xray/routing/RoutingTab.css deleted file mode 100644 index d920baf..0000000 --- a/frontend/src/pages/xray/routing/RoutingTab.css +++ /dev/null @@ -1,243 +0,0 @@ -.action-cell { - display: flex; - align-items: center; - gap: 6px; -} - -.drag-handle { - cursor: grab; - opacity: 0.35; - font-size: 14px; - padding: 4px; - margin: -4px; - touch-action: none; - transition: opacity 0.15s; -} - -.drag-handle:hover { - opacity: 0.8; -} - -.drag-handle:active { - cursor: grabbing; -} - -.row-dragging { - opacity: 0.4; -} - -.drop-before > td { - box-shadow: inset 0 2px 0 0 var(--ant-color-primary); -} - -.drop-after > td { - box-shadow: inset 0 -2px 0 0 var(--ant-color-primary); -} - -.row-index { - font-weight: 500; - opacity: 0.7; - min-width: 18px; - text-align: right; -} - -.action-buttons { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 4px; - margin-left: auto; -} - -.criterion-flow { - display: flex; - flex-direction: column; - gap: 2px; - font-size: 12px; -} - -.criterion-row { - display: inline-flex; - align-items: baseline; - gap: 4px; - white-space: nowrap; -} - -.criterion-label { - font-size: 10px; - text-transform: uppercase; - opacity: 0.55; - letter-spacing: 0.04em; -} - -.criterion-value { - font-weight: 500; -} - -.criterion-more { - font-size: 11px; - padding: 0 5px; - border-radius: 8px; - background: var(--ant-color-fill-tertiary); -} - -.criterion-empty { - opacity: 0.4; -} - -.target-row { - display: flex; - align-items: center; - gap: 4px; -} - -.target-icon { - font-size: 12px; - opacity: 0.6; -} - -.rule-list { - display: flex; - flex-direction: column; - gap: 14px; -} - -.rule-card { - position: relative; - display: flex; - flex-direction: column; - gap: 8px; - padding: 10px 12px; - background: var(--bg-card, #fff); - border: 1px solid var(--ant-color-border); - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - transition: - opacity 0.15s, - box-shadow 0.15s; -} - -.rule-list > .rule-card:not(:last-child)::after { - content: ''; - position: absolute; - left: 50%; - bottom: -8px; - width: 32px; - height: 1px; - background: var(--ant-color-border); - transform: translateX(-50%); - opacity: 0.7; -} - -.rule-card.row-dragging { - opacity: 0.4; -} - -.rule-card.drop-before { - box-shadow: inset 0 2px 0 0 var(--ant-color-primary); -} - -.rule-card.drop-after { - box-shadow: inset 0 -2px 0 0 var(--ant-color-primary); -} - -.rule-card-head { - display: flex; - align-items: center; - gap: 8px; -} - -.rule-number { - font-weight: 600; - font-size: 13px; - opacity: 0.75; - flex: 1; -} - -.rule-flow { - display: flex; - align-items: center; - gap: 8px; -} - -.flow-side { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; -} - -.flow-side-target { - align-items: flex-end; - text-align: right; -} - -.flow-label { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.55; -} - -.flow-tag { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin: 0; -} - -.flow-arrow { - font-size: 16px; - opacity: 0.45; - flex-shrink: 0; -} - -.rule-criteria { - display: flex; - flex-wrap: wrap; - gap: 4px; - padding-top: 6px; - border-top: 1px dashed var(--ant-color-border); -} - -.criterion-chip { - display: inline-flex; - align-items: baseline; - gap: 4px; - padding: 1px 6px; - font-size: 11px; - background: var(--ant-color-fill-tertiary); - border-radius: 4px; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.criterion-chip-label { - font-size: 9px; - text-transform: uppercase; - letter-spacing: 0.04em; - opacity: 0.6; -} - -.criterion-chip-value { - font-weight: 500; -} - -.rule-empty { - padding: 24px; - text-align: center; - opacity: 0.4; -} - -.hint-alert { - text-align: center; -} - -.rule-disabled { - opacity: 0.5; - filter: grayscale(1); -} diff --git a/frontend/src/pages/xray/routing/RoutingTab.tsx b/frontend/src/pages/xray/routing/RoutingTab.tsx deleted file mode 100644 index e5d9a6f..0000000 --- a/frontend/src/pages/xray/routing/RoutingTab.tsx +++ /dev/null @@ -1,461 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd'; -import { - ControlOutlined, - ExportOutlined, - ImportOutlined, - MoreOutlined, - PlusOutlined, - UnorderedListOutlined, -} from '@ant-design/icons'; - -import { catTabLabel } from '@/pages/settings/catTabLabel'; -import PromptModal from '@/components/feedback/PromptModal'; -import TextModal from '@/components/feedback/TextModal'; -import { isBalancerLoopbackTag } from '../balancers/balancer-loopback'; -import RoutingBasic from './RoutingBasic'; -import RuleFormModal from './RuleFormModal'; -import type { RoutingRule } from './RuleFormModal'; -import RuleCardList from './RuleCardList'; -import { useRoutingColumns } from './useRoutingColumns'; -import { arrJoin, originalRuleIndex } from './helpers'; -import type { RuleRow } from './types'; -import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting'; -import type { RuleObject } from '@/schemas/routing'; -import './RoutingTab.css'; - -interface RoutingTabProps { - templateSettings: XraySettingsValue | null; - setTemplateSettings: SetTemplate; - inboundTags: string[]; - clientReverseTags: string[]; - subscriptionOutboundTags?: string[]; - isMobile: boolean; -} - -export default function RoutingTab({ - templateSettings, - setTemplateSettings, - inboundTags, - clientReverseTags, - subscriptionOutboundTags, - isMobile, -}: RoutingTabProps) { - const { t } = useTranslation(); - const [modal, modalContextHolder] = Modal.useModal(); - const [ruleModalOpen, setRuleModalOpen] = useState(false); - const [editingRule, setEditingRule] = useState(null); - const [editingIndex, setEditingIndex] = useState(null); - const [draggedIndex, setDraggedIndex] = useState(null); - const [dropTargetIndex, setDropTargetIndex] = useState(null); - const dragRef = useRef<{ - from: number | null; - to: number | null; - startY: number; - moved: boolean; - }>({ - from: null, - to: null, - startY: 0, - moved: false, - }); - - const rules = useMemo( - () => (templateSettings?.routing?.rules || []) as RoutingRule[], - [templateSettings?.routing?.rules], - ); - const rulesRef = useRef(rules); - const rowsRef = useRef([]); - - const rows: RuleRow[] = useMemo( - () => - rules - .map((rule, idx) => { - const r: RuleRow = { key: idx }; - r.enabled = rule.enabled !== false; - r.domain = arrJoin(rule.domain); - r.ip = arrJoin(rule.ip); - r.port = rule.port; - r.sourcePort = rule.sourcePort; - r.vlessRoute = rule.vlessRoute; - r.network = rule.network; - r.sourceIP = arrJoin(rule.sourceIP); - r.user = arrJoin(rule.user); - r.inboundTag = arrJoin(rule.inboundTag); - r.protocol = arrJoin(rule.protocol); - if (rule.attrs && typeof rule.attrs === 'object' && !Array.isArray(rule.attrs)) { - r.attrs = JSON.stringify(rule.attrs, null, 2); - } - r.outboundTag = rule.outboundTag; - r.balancerTag = rule.balancerTag; - return r; - }) - .filter((r) => { - const inboundTags = (rules[r.key]?.inboundTag || []) as string[]; - return !inboundTags.some(isBalancerLoopbackTag); - }), - [rules], - ); - - useEffect(() => { - rulesRef.current = rules; - rowsRef.current = rows; - }); - - const mutate = useCallback( - (mutator: (next: XraySettingsValue) => void) => { - setTemplateSettings((prev) => { - if (!prev) return prev; - const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue; - mutator(clone); - return clone; - }); - }, - [setTemplateSettings], - ); - - const inboundTagOptions = useMemo(() => { - const seen = new Set(); - const out: string[] = []; - const push = (tag?: string) => { - if (!tag || seen.has(tag) || isBalancerLoopbackTag(tag)) return; - seen.add(tag); - out.push(tag); - }; - for (const ib of (templateSettings?.inbounds as Array<{ tag?: string }>) || []) push(ib?.tag); - for (const tag of inboundTags || []) push(tag); - for (const ob of templateSettings?.outbounds || []) { - const obx = ob as { - reverse?: { tag?: string }; - settings?: { reverse?: { tag?: string }; inboundTag?: string }; - }; - push(obx?.reverse?.tag || obx?.settings?.reverse?.tag || obx?.settings?.inboundTag); - } - push((templateSettings?.dns as { tag?: string } | undefined)?.tag); - for (const s of (templateSettings?.dns as { servers?: Array<{ tag?: string }> } | undefined) - ?.servers || []) { - if (typeof s === 'object' && s?.tag) push(s.tag); - } - return out; - }, [templateSettings, inboundTags]); - - const outboundTagOptions = useMemo(() => { - const out = new Set(['']); - for (const ob of templateSettings?.outbounds || []) { - if (ob?.tag) out.add(ob.tag); - } - for (const tag of clientReverseTags || []) { - if (tag) out.add(tag); - } - for (const tag of subscriptionOutboundTags || []) { - if (tag) out.add(tag); - } - return [...out]; - }, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]); - - const balancerTagOptions = useMemo(() => { - const out: string[] = ['']; - for (const b of (templateSettings?.routing?.balancers as Array<{ tag?: string }>) || []) { - if (b?.tag) out.push(b.tag); - } - return out; - }, [templateSettings?.routing?.balancers]); - - const [importOpen, setImportOpen] = useState(false); - const [exportOpen, setExportOpen] = useState(false); - const [exportContent, setExportContent] = useState(''); - - function exportRules() { - setExportContent(JSON.stringify(rules, null, 2)); - setExportOpen(true); - } - - function importRules(value: string) { - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { - message.error(t('pages.xray.importInvalidJson')); - return; - } - const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } }; - const list = Array.isArray(parsed) - ? parsed - : Array.isArray(obj?.rules) - ? obj.rules - : Array.isArray(obj?.routing?.rules) - ? obj.routing!.rules - : null; - if (!list) { - message.error(t('pages.xray.importInvalidJson')); - return; - } - mutate((tt) => { - if (!tt.routing) tt.routing = { rules: [] }; - if (!Array.isArray(tt.routing.rules)) tt.routing.rules = []; - tt.routing.rules.push(...(list as RuleObject[])); - }); - setImportOpen(false); - } - - function openAdd() { - setEditingRule(null); - setEditingIndex(null); - setRuleModalOpen(true); - } - function openEdit(idx: number) { - const target = originalRuleIndex(rowsRef.current, idx); - setEditingRule(rulesRef.current[target]); - setEditingIndex(target); - setRuleModalOpen(true); - } - function onRuleConfirm(rule: Record) { - if (JSON.stringify(rule).length <= 3) { - setRuleModalOpen(false); - return; - } - mutate((tt) => { - if (!tt.routing) tt.routing = { rules: [] }; - if (!Array.isArray(tt.routing.rules)) tt.routing.rules = []; - const typed = rule as unknown as RuleObject; - if (editingIndex == null) tt.routing.rules.push(typed); - else tt.routing.rules[editingIndex] = typed; - }); - setRuleModalOpen(false); - } - - function confirmDelete(idx: number) { - const target = originalRuleIndex(rowsRef.current, idx); - modal.confirm({ - title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`, - okText: t('delete'), - okType: 'danger', - cancelText: t('cancel'), - onOk: () => - mutate((tt) => { - tt.routing?.rules?.splice(target, 1); - }), - }); - } - - function moveUp(idx: number) { - if (idx <= 0) return; - const target = originalRuleIndex(rowsRef.current, idx); - const prev = originalRuleIndex(rowsRef.current, idx - 1); - mutate((tt) => { - const list = tt.routing?.rules; - if (!list || !list[target] || !list[prev]) return; - [list[prev], list[target]] = [list[target], list[prev]]; - }); - } - function moveDown(idx: number) { - if (idx >= rowsRef.current.length - 1) return; - const target = originalRuleIndex(rowsRef.current, idx); - const next = originalRuleIndex(rowsRef.current, idx + 1); - mutate((tt) => { - const list = tt.routing?.rules; - if (!list || !list[target] || !list[next]) return; - [list[next], list[target]] = [list[target], list[next]]; - }); - } - function toggleRule(idx: number, enabled: boolean) { - const target = originalRuleIndex(rowsRef.current, idx); - mutate((tt) => { - const list = tt.routing?.rules; - if (!list || !list[target]) return; - list[target].enabled = enabled; - }); - } - - function onHandlePointerDown(idx: number, ev: React.PointerEvent) { - if (ev.button != null && ev.button !== 0) return; - ev.preventDefault(); - try { - (ev.currentTarget as Element).setPointerCapture(ev.pointerId); - } catch { - /* ignore */ - } - dragRef.current = { from: idx, to: idx, startY: ev.clientY, moved: false }; - setDraggedIndex(idx); - setDropTargetIndex(idx); - - const onMove = (e: PointerEvent) => { - const state = dragRef.current; - if (state.from == null) return; - if (!state.moved && Math.abs(e.clientY - state.startY) < 5) return; - state.moved = true; - const el = document.elementFromPoint(e.clientX, e.clientY); - if (!el) return; - const target = el.closest('[data-row-key]'); - if (!target) return; - const newIdx = Number(target.getAttribute('data-row-key')); - if (Number.isFinite(newIdx) && newIdx !== state.to) { - state.to = newIdx; - setDropTargetIndex(newIdx); - } - }; - - const onUp = () => { - document.removeEventListener('pointermove', onMove); - document.removeEventListener('pointerup', onUp); - document.removeEventListener('pointercancel', onUp); - const { from, to, moved } = dragRef.current; - dragRef.current = { from: null, to: null, startY: 0, moved: false }; - setDraggedIndex(null); - setDropTargetIndex(null); - if (!moved || from == null || to == null || from === to) return; - const fromOrig = originalRuleIndex(rowsRef.current, from); - const toOrig = originalRuleIndex(rowsRef.current, to); - mutate((tt) => { - const list = tt.routing?.rules; - if (!list) return; - const [movedItem] = list.splice(fromOrig, 1); - list.splice(toOrig, 0, movedItem); - }); - }; - - document.addEventListener('pointermove', onMove); - document.addEventListener('pointerup', onUp); - document.addEventListener('pointercancel', onUp); - } - - const hasSource = rows.some((r) => r.sourceIP || r.sourcePort || r.vlessRoute); - const hasBalancer = rows.some((r) => r.balancerTag); - - const desktopColumns = useRoutingColumns({ - isMobile, - rowsLength: rows.length, - showSource: hasSource, - showBalancer: hasBalancer, - onHandlePointerDown, - openEdit, - moveUp, - moveDown, - confirmDelete, - toggleRule, - }); - - const tableScrollX = desktopColumns.reduce((sum, c) => { - const col = c as { width?: number; hidden?: boolean }; - return col.hidden ? sum : sum + (typeof col.width === 'number' ? col.width : 0); - }, 0); - - return ( - <> - {modalContextHolder} - , t('pages.xray.basicRouting'), isMobile), - children: ( - - ), - }, - { - key: 'rules', - label: catTabLabel(, t('pages.xray.Routings'), isMobile), - children: ( - - - - , - label: t('pages.xray.importRules'), - onClick: () => setImportOpen(true), - }, - { - key: 'export', - icon: , - label: t('pages.xray.exportRules'), - disabled: rules.length === 0, - onClick: exportRules, - }, - ], - }} - > - - - - - {isMobile ? ( - - ) : ( - r.key} - pagination={false} - scroll={{ x: tableScrollX }} - size="small" - className="routing-table" - onRow={(_record, index) => { - const classes: string[] = []; - const i = index ?? -1; - if (draggedIndex === i) classes.push('row-dragging'); - if (dropTargetIndex === i && draggedIndex !== i && draggedIndex != null) { - classes.push(i > draggedIndex ? 'drop-after' : 'drop-before'); - } - return { - className: classes.join(' '), - 'data-row-key': i, - } as React.HTMLAttributes; - }} - /> - )} - - ), - }, - ]} - /> - setRuleModalOpen(false)} - onConfirm={onRuleConfirm} - /> - setImportOpen(false)} - title={t('pages.xray.importRules')} - okText={t('pages.xray.importRules')} - type="textarea" - json - onConfirm={importRules} - /> - setExportOpen(false)} - title={t('pages.xray.exportRules')} - content={exportContent} - fileName="routing-rules.json" - json - /> - - ); -} diff --git a/frontend/src/pages/xray/routing/RuleCardList.tsx b/frontend/src/pages/xray/routing/RuleCardList.tsx deleted file mode 100644 index 3b2f401..0000000 --- a/frontend/src/pages/xray/routing/RuleCardList.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Tag, Tooltip, Switch } from 'antd'; -import { - MoreOutlined, - EditOutlined, - DeleteOutlined, - ExportOutlined, - ClusterOutlined, - ArrowUpOutlined, - ArrowDownOutlined, - HolderOutlined, -} from '@ant-design/icons'; - -import { useInboundOptions } from '@/api/queries/useInboundOptions'; -import { - buildRemarkByTag, - chipPreview, - inboundTagChipPreview, - inboundTagsDisplayTitle, - isApiRule, - ruleCriteriaChips, -} from './helpers'; -import type { RuleRow } from './types'; - -interface RuleCardListProps { - rows: RuleRow[]; - draggedIndex: number | null; - dropTargetIndex: number | null; - onHandlePointerDown: (idx: number, ev: React.PointerEvent) => void; - openEdit: (idx: number) => void; - moveUp: (idx: number) => void; - moveDown: (idx: number) => void; - confirmDelete: (idx: number) => void; - toggleRule: (idx: number, enabled: boolean) => void; -} - -export default function RuleCardList({ - rows, - draggedIndex, - dropTargetIndex, - onHandlePointerDown, - openEdit, - moveUp, - moveDown, - confirmDelete, - toggleRule, -}: RuleCardListProps) { - const { t } = useTranslation(); - const { data: inboundOptions } = useInboundOptions(); - const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]); - return ( -
- {rows.length === 0 ? ( -
—
- ) : ( - rows.map((rule, index) => ( -
draggedIndex ? 'drop-after' : ''} ${ - rule.enabled === false ? 'rule-disabled' : '' - }`} - data-row-key={index} - > -
-
- -
-
- {t('pages.xray.Inbounds')} - {rule.inboundTag ? ( - - - {inboundTagChipPreview(rule.inboundTag, remarkByTag)} - - - ) : ( - any - )} -
- → -
- - {rule.balancerTag - ? t('pages.xray.balancer') || 'Balancer' - : t('pages.xray.Outbounds')} - - {rule.outboundTag ? ( - - - ) : rule.balancerTag ? ( - - - ) : ( - — - )} -
-
- - {ruleCriteriaChips(rule).length > 0 && ( -
- {ruleCriteriaChips(rule).map((chip) => ( - - - {chip.label} - {chipPreview(chip.value)} - - - ))} -
- )} -
- )) - )} -
- ); -} diff --git a/frontend/src/pages/xray/routing/RuleFormModal.tsx b/frontend/src/pages/xray/routing/RuleFormModal.tsx deleted file mode 100644 index 08c1189..0000000 --- a/frontend/src/pages/xray/routing/RuleFormModal.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import { useEffect, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd'; -import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons'; -import { FormProvider, useForm, useWatch } from 'react-hook-form'; -import { InputAddon } from '@/components/ui'; -import { GeoTokenInput } from '@/components/geodata'; -import { FormField } from '@/components/form/rhf'; -import { useClientOptions } from '@/api/queries/useClientOptions'; -import { useInboundOptions } from '@/api/queries/useInboundOptions'; -import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray'; -import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers'; - -export interface RoutingRule { - enabled?: boolean; - type?: string; - domain?: string | string[]; - ip?: string | string[]; - port?: string; - sourcePort?: string; - vlessRoute?: string; - network?: string; - sourceIP?: string | string[]; - user?: string | string[]; - inboundTag?: string[]; - protocol?: string[]; - attrs?: Record; - outboundTag?: string; - balancerTag?: string; - [key: string]: unknown; -} - -interface RuleFormModalProps { - open: boolean; - rule: RoutingRule | null; - inboundTags: string[]; - outboundTags: string[]; - balancerTags: string[]; - onClose: () => void; - onConfirm: (rule: Record) => void; -} - -const initialForm = (): RuleFormValues => ({ - enabled: true, - domain: '', - ip: '', - port: '', - sourcePort: '', - vlessRoute: '', - network: '', - sourceIP: '', - user: '', - inboundTag: [], - protocol: [], - attrs: [], - outboundTag: '', - balancerTag: '', -}); - -const NETWORKS = ['', 'tcp', 'udp', 'tcp,udp']; -const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic']; - -function csv(value: string): string[] { - if (!value) return []; - return value - .split(',') - .map((s) => s.trim()) - .filter(Boolean); -} - -export default function RuleFormModal({ - open, - rule, - inboundTags, - outboundTags, - balancerTags, - onClose, - onConfirm, -}: RuleFormModalProps) { - const { t } = useTranslation(); - const methods = useForm({ defaultValues: initialForm() }); - const isEdit = rule != null; - - const { data: inboundOptions } = useInboundOptions(); - const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]); - const { - data: clientEmails = [], - isFetching: clientsLoading, - isError: clientsError, - } = useClientOptions(open); - const user = useWatch({ control: methods.control, name: 'user' }) ?? ''; - const selectedUsers = useMemo(() => csv(user), [user]); - const userOptions = useMemo( - () => - [...new Set([...clientEmails, ...selectedUsers])].map((email) => ({ - value: email, - label: email, - })), - [clientEmails, selectedUsers], - ); - - useEffect(() => { - if (!open) return; - if (rule) { - methods.reset({ - enabled: rule.enabled !== false, - domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '', - ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '', - port: rule.port || '', - sourcePort: rule.sourcePort || '', - vlessRoute: rule.vlessRoute || '', - network: rule.network || '', - sourceIP: Array.isArray(rule.sourceIP) ? rule.sourceIP.join(',') : rule.sourceIP || '', - user: Array.isArray(rule.user) ? rule.user.join(',') : rule.user || '', - inboundTag: rule.inboundTag || [], - protocol: rule.protocol || [], - attrs: rule.attrs ? Object.entries(rule.attrs) : [], - outboundTag: rule.outboundTag || '', - balancerTag: rule.balancerTag || '', - }); - } else { - methods.reset(initialForm()); - } - }, [open, rule, methods]); - - const attrs = useWatch({ control: methods.control, name: 'attrs' }) ?? []; - - function submit() { - const validated = RuleFormSchema.safeParse(methods.getValues()); - if (!validated.success) return; - const v = validated.data; - const built: Record = { - type: 'field', - enabled: v.enabled, - domain: csv(v.domain), - ip: csv(v.ip), - port: v.port, - sourcePort: v.sourcePort, - vlessRoute: v.vlessRoute, - network: v.network, - sourceIP: csv(v.sourceIP), - user: csv(v.user), - inboundTag: v.inboundTag, - protocol: v.protocol, - attrs: Object.fromEntries(v.attrs.filter(([k]) => k)), - outboundTag: v.outboundTag === '' ? undefined : v.outboundTag, - balancerTag: v.balancerTag === '' ? undefined : v.balancerTag, - }; - const managedKeys = new Set(Object.keys(built)); - const out: Record = {}; - if (rule) { - for (const [key, value] of Object.entries(rule)) { - if (!managedKeys.has(key) && value !== undefined) out[key] = value; - } - } - for (const [k, v] of Object.entries(built)) { - if (v == null) continue; - if (Array.isArray(v) && v.length === 0) continue; - if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) continue; - if (v === '') continue; - out[k] = v; - } - onConfirm(out); - } - - const title = isEdit - ? `${t('edit')} ${t('pages.xray.Routings')}` - : `+ ${t('pages.xray.Routings')}`; - const okText = isEdit ? t('pages.clients.submitEdit') : t('create'); - - return ( - - -
- - - - - - {t('pages.xray.ruleForm.sourceIps')} - - - {t('pages.xray.ruleForm.sourcePort')} - - - {t('pages.xray.ruleForm.vlessRoute')} - - - ({ value: p, label: p }))} /> - - - -