ITEM6 B4: frontend reshaping — xray page retired, stats switches to settings, template read paths closed

- 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.
This commit is contained in:
Muse
2026-10-22 23:40:54 +00:00
parent 1762e54d5f
commit 277838d7ca
151 changed files with 780 additions and 22140 deletions
-111
View File
@@ -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<GeoFile[]> {
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<GeoCategoryPage> {
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<GeoEntryPage> {
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<GeodataTokenIssue[], Error, ValidateGeoTokensInput>({
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 : [];
},
});
}
+41 -23
View File
@@ -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<OutboundDirectory> {
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<OutboundDirectory>;
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<string>();
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<string>();
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 };
},
+1 -13
View File
@@ -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;
-7
View File
@@ -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) {
@@ -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;
}
@@ -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<string, GeoResponder>;
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<Response> {
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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
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<string, GeoEntry[]> = {
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<string, string[]> = {
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<string, GeoEntry[]> = {
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<string, GeoEntry[]>,
attributes: Record<string, string[]> = {},
): 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<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
{
'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 (
<GeoApi routes={routes}>
<Story />
</GeoApi>
);
};
}
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 (
<Space orientation="vertical" size={12}>
<Space size={8}>
<Button onClick={() => setOpen(true)}>Open geo browser</Button>
<Typography.Text code>{value || 'no rule yet'}</Typography.Text>
</Space>
<GeoBrowserModal
{...props}
open={open}
value={value}
onApply={(next) => {
setValue(next);
setOpen(false);
}}
onClose={() => setOpen(false)}
/>
</Space>
);
}
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) => <BrowserDemo {...args} />,
} satisfies Meta<typeof GeoBrowserModal>;
export default meta;
type Story = StoryObj<typeof meta>;
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: '' },
};
@@ -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<string | undefined>(undefined);
const [categoryQuery, setCategoryQuery] = useState('');
const [activeCode, setActiveCode] = useState<string | undefined>(undefined);
const [entryQuery, setEntryQuery] = useState('');
const [entryFilter, setEntryFilter] = useState('');
const [entryPage, setEntryPage] = useState(1);
const [selected, setSelected] = useState<string[]>([]);
const [known, setKnown] = useState<Set<string>>(() => new Set());
const [seededFiles, setSeededFiles] = useState<Set<string>>(() => 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<GeoCategory> = useMemo(
() => [
{
title: t('pages.xray.geoBrowser.searchCategory'),
dataIndex: 'code',
render: (code: string, category: GeoCategory) => (
<span className="geo-category">
<span className="geo-code">{code}</span>
{category.attributes?.length > 0 && (
<span className="geo-attrs">
{category.attributes.map((attribute) => (
<Tag key={attribute} variant="filled">
@{attribute}
</Tag>
))}
</span>
)}
</span>
),
},
{
dataIndex: 'entries',
align: 'right',
width: 90,
render: (entries: number) => <span className="geo-count">{entries.toLocaleString()}</span>,
},
],
[t],
);
const entryColumns: ColumnsType<GeoEntry> = useMemo(
() => [
{
dataIndex: 'kind',
width: 88,
render: (entryKind: string) => (
<Tag variant="filled" className={`geo-kind geo-kind-${entryKind}`}>
{entryKind}
</Tag>
),
},
{
dataIndex: 'value',
render: (entryValue: string) => <span className="geo-entry-value">{entryValue}</span>,
},
],
[],
);
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 (
<Modal
open={open}
title={t('pages.xray.geoBrowser.title')}
width={880}
onCancel={onClose}
onOk={() => onApply(mergeSelection(value, selected, known))}
okText={t('pages.xray.geoBrowser.apply')}
cancelText={t('close')}
className="geo-browser-modal"
>
{filesQuery.isError && (
<Alert
type="error"
showIcon
title={t('pages.xray.geoBrowser.loadFailed')}
className="mb-12"
/>
)}
{!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? (
<Empty
description={
<span>
{t('pages.xray.geoBrowser.noFiles')}
<br />
<Typography.Text type="secondary">
{t('pages.xray.geoBrowser.noFilesHint')}
</Typography.Text>
</span>
}
/>
) : (
<>
<div className="geo-toolbar">
<Select
value={file}
options={fileOptions}
onChange={(next) => {
setFile(next);
setActiveCode(undefined);
setCategoryQuery('');
clearEntryFilter();
}}
style={{ minWidth: 200 }}
aria-label={t('pages.xray.geoBrowser.database')}
/>
<Input.Search
value={categoryQuery}
onChange={(event) => setCategoryQuery(event.target.value)}
placeholder={t('pages.xray.geoBrowser.searchCategory')}
allowClear
/>
<Button
onClick={() =>
toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])
}
disabled={visibleCategories.length === 0}
>
{`${t('pages.xray.geoBrowser.selectFound')} (${visibleCategories.length.toLocaleString()})`}
</Button>
<span className="geo-meta">{meta}</span>
</div>
<div className="geo-columns">
<div className="geo-panel geo-categories">
<Table
size="small"
virtual
showHeader={false}
rowKey="code"
columns={categoryColumns}
dataSource={visibleCategories}
loading={
filesQuery.isLoading ||
categoriesQuery.isLoading ||
categoriesQuery.isPlaceholderData
}
pagination={false}
scroll={{ y: CATEGORY_SCROLL_HEIGHT }}
locale={{ emptyText: t('pages.xray.geoBrowser.noMatches') }}
rowSelection={{
columnWidth: 42,
preserveSelectedRowKeys: true,
selectedRowKeys: selectedCodes,
onChange: (keys) => toggle(keys as string[]),
}}
onRow={(category) => ({
onClick: (event) => {
if ((event.target as HTMLElement).closest('.ant-table-selection-column'))
return;
setActiveCode(category.code);
clearEntryFilter();
},
})}
rowClassName={(category) => (category.code === activeCode ? 'geo-row-active' : '')}
/>
</div>
<div className="geo-panel geo-preview">
{activeCode ? (
<>
<div className="geo-preview-head">
<Tooltip title={file ? tokenFor(file, activeCode, fileKind) : activeCode}>
<span className="geo-preview-title">{activeCode}</span>
</Tooltip>
<Typography.Text type="secondary">{countLabel}</Typography.Text>
<Input
value={entryQuery}
onChange={(event) => setEntryQuery(event.target.value)}
placeholder={t('pages.xray.geoBrowser.searchEntries')}
allowClear
className="geo-entry-filter"
/>
</div>
<div className="geo-preview-body">
<Table
size="small"
showHeader={false}
rowKey={(entry, index) => `${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}
/>
</div>
<div className="geo-pager">
<Pagination
current={entryPage}
pageSize={ENTRY_PAGE_SIZE}
total={entriesTotal}
size="small"
showSizeChanger={false}
onChange={setEntryPage}
showTotal={(total, range) =>
t('pages.xray.geoBrowser.shownRange', {
from: range[0].toLocaleString(),
to: range[1].toLocaleString(),
total: total.toLocaleString(),
})
}
/>
</div>
</>
) : (
<div className="geo-placeholder">
<Typography.Text type="secondary">
{t('pages.xray.geoBrowser.pickCategory')}
</Typography.Text>
</div>
)}
</div>
</div>
<div className="geo-footer">
{selected.length === 0 ? (
<Typography.Text type="secondary">
{t('pages.xray.geoBrowser.emptySelection')}
</Typography.Text>
) : (
<>
<Space size={4} wrap className="geo-chips">
{selected.map((token) => (
<Tag
key={token}
closable
color="processing"
onClose={() =>
setSelected((previous) => previous.filter((item) => item !== token))
}
>
{token}
</Tag>
))}
</Space>
<span className="geo-selected-count">
{t('pages.xray.geoBrowser.selected', { count: selected.length })}
</span>
<Button type="link" size="small" onClick={() => setSelected([])}>
{t('pages.xray.geoBrowser.clearAll')}
</Button>
</>
)}
</div>
</>
)}
</Modal>
);
}
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');
}
@@ -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<string, GeoResponder>;
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<Response> {
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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
'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<string, GeoEntry[]> = {
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<string, string[]> = {
google: ['ads', 'cn'],
youtube: ['ads'],
};
function categoriesOf(
entries: Record<string, GeoEntry[]>,
attributes: Record<string, string[]> = {},
): GeoCategory[] {
return Object.keys(entries)
.sort()
.map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
}
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
{
'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 (
<GeoApi routes={routes}>
<Story />
</GeoApi>
);
};
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 (
<Space orientation="vertical" size={4} style={{ width: 460 }}>
<label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
<GeoTokenInput {...rest} id={id} value={current} onChange={setCurrent} />
</Space>
);
}
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) => <ControlledTokenInput {...args} />,
} satisfies Meta<typeof GeoTokenInput>;
export default meta;
type Story = StoryObj<typeof meta>;
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();
},
};
@@ -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<string, string> = {
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<InputRef>;
}
export default function GeoTokenInput({
value = '',
onChange,
onBlur,
kind,
placeholder,
id,
ref,
}: GeoTokenInputProps) {
const { t } = useTranslation();
const [browsing, setBrowsing] = useState(false);
const [issues, setIssues] = useState<GeodataTokenIssue[]>([]);
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 (
<>
<Space.Compact block>
<Input
ref={ref}
id={id}
value={value}
placeholder={placeholder}
onChange={(event) => onChange?.(event.target.value)}
onBlur={onBlur}
/>
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
<Button
icon={<DatabaseOutlined />}
aria-label={t('pages.xray.geoBrowser.openTooltip')}
onClick={() => setBrowsing(true)}
/>
</Tooltip>
</Space.Compact>
{groupByReason(issues).map(([reason, tokens]) => (
<Typography.Text key={reason} type="warning" className="geo-unknown-hint">
{t(REASON_KEYS[reason] ?? REASON_KEYS.categoryMissing, { tokens: tokens.join(', ') })}
</Typography.Text>
))}
{checkFailed && (
<Typography.Text type="secondary" className="geo-unknown-hint">
{t('pages.xray.geoBrowser.checkFailed')}
</Typography.Text>
)}
<GeoBrowserModal
open={browsing}
kind={(kind === 'ip' ? 'ip' : 'site') as GeoKind}
value={value}
onApply={(next) => {
onChange?.(next);
setBrowsing(false);
}}
onClose={() => setBrowsing(false)}
/>
</>
);
}
function groupByReason(issues: GeodataTokenIssue[]): Array<[string, string[]]> {
const grouped = new Map<string, string[]>();
for (const issue of issues) {
const tokens = grouped.get(issue.reason) ?? [];
tokens.push(issue.token);
grouped.set(issue.reason, tokens);
}
return [...grouped];
}
-4
View File
@@ -1,4 +0,0 @@
export { default as GeoBrowserModal } from './GeoBrowserModal';
export type { GeoBrowserModalProps } from './GeoBrowserModal';
export { default as GeoTokenInput } from './GeoTokenInput';
export type { GeoTokenInputProps } from './GeoTokenInput';
+19 -6
View File
@@ -54,6 +54,7 @@ export const EXAMPLES: Record<string, unknown> = {
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trafficStatsSwitches": "",
"trustedProxyCIDRs": "",
"warpUpdateInterval": 0,
"webBasePath": "",
@@ -121,6 +122,7 @@ export const EXAMPLES: Record<string, unknown> = {
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trafficStatsSwitches": "",
"trustedProxyCIDRs": "",
"warpUpdateInterval": 0,
"webBasePath": "",
@@ -290,12 +292,6 @@ export const EXAMPLES: Record<string, unknown> = {
"name": "geosite.dat",
"size": 1467392
},
"GeodataTokenIssue": {
"code": "blabla",
"file": "geosite.dat",
"reason": "categoryMissing",
"token": "geosite:blabla"
},
"HistoryOfSeeders": {
"id": 0,
"seederName": ""
@@ -548,7 +544,13 @@ export const EXAMPLES: Record<string, unknown> = {
},
"NodeView": {
"activeCount": 20,
"activeEmails": [
"a@example.com"
],
"address": "node.example.com",
"agentUpdateError": "",
"agentUpdateState": "failed",
"agentUpdateTarget": "A1.0.1",
"allowPrivateAddress": false,
"basePath": "/",
"clientCount": 25,
@@ -557,7 +559,13 @@ export const EXAMPLES: Record<string, unknown> = {
"cpuPct": 12.5,
"createdAt": 1700000000,
"depletedCount": 1,
"depletedEmails": [
"c@example.com"
],
"disabledCount": 2,
"disabledEmails": [
"b@example.com"
],
"enable": true,
"guid": "node-guid",
"hasApiToken": true,
@@ -567,6 +575,8 @@ export const EXAMPLES: Record<string, unknown> = {
"inboundTags": [
"in-443-tcp"
],
"ipv4": "203.0.113.7",
"ipv6": "2001:db8::7",
"lastError": "",
"lastHeartbeat": 1700000000,
"latencyMs": 42,
@@ -576,6 +586,9 @@ export const EXAMPLES: Record<string, unknown> = {
"netUp": 2097152,
"nodeType": "traditional",
"onlineCount": 5,
"onlineEmails": [
"a@example.com"
],
"outboundTag": "direct",
"panelVersion": "v3.x.x",
"parentGuid": "",
+68 -26
View File
@@ -179,6 +179,10 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"trafficStatsSwitches": {
"description": "TrafficStatsSwitches (ITEM6 §4.3.1): the whole-network traffic\naccounting switches as one JSON string\n({inboundUplink,inboundDownlink,outboundUplink,outboundDownlink}),\ncarried verbatim so the settings page edits the same key the\nsing-box generator reads (settings.trafficStatsSwitches).",
"type": "string"
},
"trustedProxyCIDRs": {
"type": "string"
},
@@ -261,6 +265,7 @@ export const SCHEMAS: Record<string, unknown> = {
"tgRunTime",
"timeLocation",
"trafficDiff",
"trafficStatsSwitches",
"trustedProxyCIDRs",
"warpUpdateInterval",
"webBasePath",
@@ -463,6 +468,10 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"trafficStatsSwitches": {
"description": "TrafficStatsSwitches (ITEM6 §4.3.1): the whole-network traffic\naccounting switches as one JSON string\n({inboundUplink,inboundDownlink,outboundUplink,outboundDownlink}),\ncarried verbatim so the settings page edits the same key the\nsing-box generator reads (settings.trafficStatsSwitches).",
"type": "string"
},
"trustedProxyCIDRs": {
"type": "string"
},
@@ -549,6 +558,7 @@ export const SCHEMAS: Record<string, unknown> = {
"tgRunTime",
"timeLocation",
"trafficDiff",
"trafficStatsSwitches",
"trustedProxyCIDRs",
"warpUpdateInterval",
"webBasePath",
@@ -1217,32 +1227,6 @@ export const SCHEMAS: Record<string, unknown> = {
],
"type": "object"
},
"GeodataTokenIssue": {
"description": "GeodataTokenIssue reports a routing token the running core would reject,\nor would silently match nothing against.",
"properties": {
"code": {
"example": "blabla",
"type": "string"
},
"file": {
"example": "geosite.dat",
"type": "string"
},
"reason": {
"example": "categoryMissing",
"type": "string"
},
"token": {
"example": "geosite:blabla",
"type": "string"
}
},
"required": [
"reason",
"token"
],
"type": "object"
},
"HistoryOfSeeders": {
"description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
"properties": {
@@ -2272,10 +2256,31 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 20,
"type": "integer"
},
"activeEmails": {
"example": [
"a@example.com"
],
"items": {
"type": "string"
},
"type": "array"
},
"address": {
"example": "node.example.com",
"type": "string"
},
"agentUpdateError": {
"type": "string"
},
"agentUpdateState": {
"description": "Agent one-click update bookkeeping (item 20), agent nodes only:\nthe last intent's state/target and, on failure, its error.",
"example": "failed",
"type": "string"
},
"agentUpdateTarget": {
"example": "A1.0.1",
"type": "string"
},
"allowPrivateAddress": {
"example": false,
"type": "boolean"
@@ -2310,10 +2315,28 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 1,
"type": "integer"
},
"depletedEmails": {
"example": [
"c@example.com"
],
"items": {
"type": "string"
},
"type": "array"
},
"disabledCount": {
"example": 2,
"type": "integer"
},
"disabledEmails": {
"example": [
"b@example.com"
],
"items": {
"type": "string"
},
"type": "array"
},
"enable": {
"example": true,
"type": "boolean"
@@ -2347,6 +2370,15 @@ export const SCHEMAS: Record<string, unknown> = {
},
"type": "array"
},
"ipv4": {
"description": "Ipv4/Ipv6 carry the resolved externally usable host addresses for\nagent nodes (P13 telemetry, public-address resolution on top).\nThe nodes page renders them as plain text on agent rows instead\nof the panel-URL link used for traditional nodes. Empty for\ntraditional nodes and for agents with no known address yet.",
"example": "203.0.113.7",
"type": "string"
},
"ipv6": {
"example": "2001:db8::7",
"type": "string"
},
"lastError": {
"type": "string"
},
@@ -2386,6 +2418,16 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 5,
"type": "integer"
},
"onlineEmails": {
"description": "Per-category client email lists behind the counts above, from the\nsame attribution bucketing, so the nodes page category chips can\npop over the actual usernames. Empty when the category is empty.",
"example": [
"a@example.com"
],
"items": {
"type": "string"
},
"type": "array"
},
"outboundTag": {
"example": "direct",
"type": "string"
+11 -7
View File
@@ -63,6 +63,7 @@ export interface AllSetting {
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trafficStatsSwitches: string;
trustedProxyCIDRs: string;
warpUpdateInterval: number;
webBasePath: string;
@@ -131,6 +132,7 @@ export interface AllSettingView {
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trafficStatsSwitches: string;
trustedProxyCIDRs: string;
warpUpdateInterval: number;
webBasePath: string;
@@ -295,13 +297,6 @@ export interface GeoFile {
size: number;
}
export interface GeodataTokenIssue {
code?: string;
file?: string;
reason: string;
token: string;
}
export interface HistoryOfSeeders {
id: number;
seederName: string;
@@ -517,7 +512,11 @@ export interface NodeMutationRequest {
export interface NodeView {
activeCount: number;
activeEmails?: string[];
address: string;
agentUpdateError?: string;
agentUpdateState?: string;
agentUpdateTarget?: string;
allowPrivateAddress: boolean;
basePath: string;
clientCount: number;
@@ -526,7 +525,9 @@ export interface NodeView {
cpuPct: number;
createdAt: number;
depletedCount: number;
depletedEmails?: string[];
disabledCount: number;
disabledEmails?: string[];
enable: boolean;
guid: string;
hasApiToken: boolean;
@@ -534,6 +535,8 @@ export interface NodeView {
inboundCount: number;
inboundSyncMode: string;
inboundTags: string[];
ipv4?: string;
ipv6?: string;
lastError: string;
lastHeartbeat: number;
latencyMs: number;
@@ -543,6 +546,7 @@ export interface NodeView {
netUp: number;
nodeType?: string;
onlineCount: number;
onlineEmails?: string[];
outboundTag: string;
panelVersion: string;
parentGuid?: string;
+11 -8
View File
@@ -81,6 +81,7 @@ export const AllSettingSchema = z.object({
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trafficStatsSwitches: z.string(),
trustedProxyCIDRs: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
@@ -150,6 +151,7 @@ export const AllSettingViewSchema = z.object({
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trafficStatsSwitches: z.string(),
trustedProxyCIDRs: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
@@ -328,14 +330,6 @@ export const GeoFileSchema = z.object({
});
export type GeoFile = z.infer<typeof GeoFileSchema>;
export const GeodataTokenIssueSchema = z.object({
code: z.string().optional(),
file: z.string().optional(),
reason: z.string(),
token: z.string(),
});
export type GeodataTokenIssue = z.infer<typeof GeodataTokenIssueSchema>;
export const HistoryOfSeedersSchema = z.object({
id: z.number().int(),
seederName: z.string(),
@@ -561,7 +555,11 @@ export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
export const NodeViewSchema = z.object({
activeCount: z.number().int(),
activeEmails: z.array(z.string()).optional(),
address: z.string(),
agentUpdateError: z.string().optional(),
agentUpdateState: z.string().optional(),
agentUpdateTarget: z.string().optional(),
allowPrivateAddress: z.boolean(),
basePath: z.string(),
clientCount: z.number().int(),
@@ -570,7 +568,9 @@ export const NodeViewSchema = z.object({
cpuPct: z.number(),
createdAt: z.number().int(),
depletedCount: z.number().int(),
depletedEmails: z.array(z.string()).optional(),
disabledCount: z.number().int(),
disabledEmails: z.array(z.string()).optional(),
enable: z.boolean(),
guid: z.string(),
hasApiToken: z.boolean(),
@@ -578,6 +578,8 @@ export const NodeViewSchema = z.object({
inboundCount: z.number().int(),
inboundSyncMode: z.string(),
inboundTags: z.array(z.string()),
ipv4: z.string().optional(),
ipv6: z.string().optional(),
lastError: z.string(),
lastHeartbeat: z.number().int(),
latencyMs: z.number().int(),
@@ -587,6 +589,7 @@ export const NodeViewSchema = z.object({
netUp: z.number().int(),
nodeType: z.string().optional(),
onlineCount: z.number().int(),
onlineEmails: z.array(z.string()).optional(),
outboundTag: z.string(),
panelVersion: z.string(),
parentGuid: z.string().optional(),
-1
View File
@@ -304,7 +304,6 @@ export function useClients(options: UseClientsOptions = {}) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
]);
}, [queryClient]);
-3
View File
@@ -9,9 +9,6 @@ const TITLE_KEYS: Record<string, string> = {
'/nodes': 'menu.nodes',
'/hosts': 'menu.hosts',
'/settings': 'menu.settings',
'/xray': 'menu.xray',
'/outbound': 'menu.outbounds',
'/routing': 'menu.routing',
};
export function usePageTitle() {
-266
View File
@@ -1,266 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
import {
OutboundTrafficListSchema,
XrayConfigPayloadSchema,
XraySettingsValueSchema,
type OutboundTrafficRow,
} from '@/schemas/xray';
export type { OutboundTrafficRow };
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export type SetTemplate = (
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
) => void;
export interface UseXraySettingResult {
fetched: boolean;
spinning: boolean;
saveDisabled: boolean;
fetchError: string;
xraySetting: string;
setXraySetting: (next: string) => void;
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
inboundTags: string[];
clientReverseTags: string[];
subscriptionOutbounds: unknown[];
subscriptionOutboundTags: string[];
outboundsTraffic: OutboundTrafficRow[];
fetchAll: () => Promise<void>;
fetchOutboundsTraffic: () => Promise<void>;
resetOutboundsTraffic: (tag: string) => Promise<void>;
saveAll: () => Promise<void>;
resetToDefault: () => Promise<void>;
}
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
if (typeof msg.obj !== 'string')
throw new Error('Malformed xray config response: expected string');
let parsed: unknown;
try {
parsed = JSON.parse(msg.obj);
} catch (e) {
const err = e as Error;
throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
}
const result = XrayConfigPayloadSchema.safeParse(parsed);
if (!result.success) {
console.warn('[zod] xray/ config payload failed validation', result.error.issues);
return parsed as XrayConfigPayload;
}
return result.data;
}
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, {
silent: true,
});
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useXraySetting(): UseXraySettingResult {
const queryClient = useQueryClient();
const configQuery = useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
staleTime: Infinity,
});
const trafficQuery = useQuery({
queryKey: keys.xray.outboundsTraffic(),
queryFn: fetchOutboundsTraffic,
staleTime: Infinity,
});
const [xraySetting, setXraySettingState] = useState('');
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
const [savedXraySetting, setSavedXraySetting] = useState('');
const config = configQuery.data;
const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
const subscriptionOutbounds = useMemo<unknown[]>(
() => config?.subscriptionOutbounds || [],
[config],
);
const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [config]);
const syncingRef = useRef(false);
const xraySettingRef = useRef('');
const savedXraySettingRef = useRef(savedXraySetting);
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]);
const [syncedConfig, setSyncedConfig] = useState<XrayConfigPayload | undefined>();
useEffect(() => {
xraySettingRef.current = xraySetting;
savedXraySettingRef.current = savedXraySetting;
templateSettingsRef.current = templateSettings;
subscriptionOutboundsRef.current = subscriptionOutbounds;
});
// Adopt a fetched config during render, so the editor never paints one frame
// of the previous config after a refetch. Local edits win over the refetch.
if (config && config !== syncedConfig) {
setSyncedConfig(config);
const isDirty = savedXraySetting !== xraySetting;
if (!isDirty) {
const pretty = JSON.stringify(config.xraySetting, null, 2);
setXraySettingState(pretty);
setTemplateSettingsState(config.xraySetting);
setSavedXraySetting(pretty);
}
}
const fetched = configQuery.data !== undefined || configQuery.isError;
const fetchError = configQuery.error ? (configQuery.error as Error).message : '';
const setXraySetting = useCallback((next: string) => {
setXraySettingState(next);
if (syncingRef.current) return;
try {
const parsed = JSON.parse(next);
syncingRef.current = true;
setTemplateSettingsState(parsed);
syncingRef.current = false;
} catch {
/* ignore — wait for user to finish */
}
}, []);
const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
setTemplateSettingsState((prev) => {
const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
if (next == null) return next;
if (!syncingRef.current) {
try {
syncingRef.current = true;
setXraySettingState(JSON.stringify(next, null, 2));
} finally {
syncingRef.current = false;
}
}
return next;
});
}, []);
const fetchAll = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
}, [queryClient]);
const fetchOutboundsTrafficCb = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
}, [queryClient]);
const saveMut = useMutation({
mutationFn: async () => {
const sentXraySetting = xraySettingRef.current;
const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: sentXraySetting,
});
return { msg, sentXraySetting };
},
onSuccess: ({ msg, sentXraySetting }) => {
if (!msg?.success) return;
setSavedXraySetting(sentXraySetting);
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
},
});
const resetTrafficMut = useMutation({
mutationFn: (tag: string) => HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
},
});
const resetDefaultMut = useMutation({
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
},
onSuccess: (msg) => {
if (msg?.success && msg.obj) {
const cloned = JSON.parse(JSON.stringify(msg.obj));
setTemplateSettings(cloned);
}
},
});
const saveAll = useCallback(async () => {
await saveMut.mutateAsync();
}, [saveMut]);
const resetOutboundsTraffic = useCallback(
async (tag: string) => {
await resetTrafficMut.mutateAsync(tag);
},
[resetTrafficMut],
);
const resetToDefault = useCallback(async () => {
await resetDefaultMut.mutateAsync();
}, [resetDefaultMut]);
const spinning = saveMut.isPending || resetDefaultMut.isPending;
const saveDisabled = savedXraySetting === xraySetting;
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
return useMemo(
() => ({
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
fetchAll,
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
resetOutboundsTraffic,
saveAll,
resetToDefault,
}),
[
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
fetchAll,
fetchOutboundsTrafficCb,
resetOutboundsTraffic,
saveAll,
resetToDefault,
],
);
}
+2 -16
View File
@@ -217,10 +217,7 @@ export default function AppSidebar() {
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
{ key: '/routing', icon: 'routing', title: t('menu.routing') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
],
[t],
@@ -269,22 +266,14 @@ export default function AppSidebar() {
return children;
}, [t, showSubFormats, showSubBalancers]);
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
() => [{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') }],
[t],
);
const settingsActive = pathname === '/settings';
const xrayActive = pathname === '/xray';
const selectedKey = settingsActive
? `/settings${hash || '#general'}`
: xrayActive
? `/xray${hash || '#basic'}`
: pathname === ''
? '/'
: pathname;
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
const openSubmenu = settingsActive ? '/settings' : null;
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
if (openSubmenu && !openKeys.includes(openSubmenu)) {
setOpenKeys([...openKeys, openSubmenu]);
@@ -297,12 +286,9 @@ export default function AppSidebar() {
if (tab.key === '/settings') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
}
if (tab.key === '/xray') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
}
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
}),
[settingsChildren, xrayChildren],
[settingsChildren],
);
const openLink = useCallback(
@@ -1,6 +1,16 @@
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import type { BalancerObject } from '@/schemas/routing';
// The settings object these helpers read/mutate: only the routing
// balancers and the two observatory sections are touched. (This was
// XraySettingsValue from the retired xray settings page's hook; the
// helpers outlived the page because the subscription balancers tab
// still consumes the defaults.)
export interface BalancerSettingsLike {
routing?: { balancers?: BalancerObject[] } | null;
observatory?: unknown;
burstObservatory?: unknown;
}
export const DEFAULT_OBSERVATORY = Object.freeze({
subjectSelector: [] as string[],
probeURL: 'https://www.google.com/generate_204',
@@ -34,7 +44,7 @@ export function balancerRequiresBurstObservatory(b: BalancerObject): boolean {
);
}
export function settingsRequireBurstObservatory(t: XraySettingsValue | null): boolean {
export function settingsRequireBurstObservatory(t: BalancerSettingsLike | null): boolean {
const balancers = (t?.routing?.balancers || []) as BalancerObject[];
return balancers.some(balancerRequiresBurstObservatory);
}
@@ -59,7 +69,7 @@ export function settingsRequireBurstObservatory(t: XraySettingsValue | null): bo
// the last leastLoad) removes the burst observer again. A no-fallback
// Random/RoundRobin balancer never expands the observer either, because those
// strategies do not consume observer data.
export function syncObservatories(t: XraySettingsValue) {
export function syncObservatories(t: BalancerSettingsLike) {
const balancers = (t.routing?.balancers || []) as BalancerObject[];
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
@@ -62,7 +62,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="IP/CIDR/geoip:*/ext:*"
placeholder="IP/CIDR"
style={{ width: '100%' }}
/>
</Form.Item>
@@ -73,7 +73,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="domain:*/ext:*"
placeholder="domain:*"
style={{ width: '100%' }}
/>
</Form.Item>
@@ -0,0 +1,2 @@
export { default as SockoptForm } from './sockopt';
export { default as MuxForm } from './mux';
-81
View File
@@ -1,81 +0,0 @@
import type { GeoKind } from '@/generated/types';
const DEFAULT_SITE_FILE = 'geosite.dat';
const DEFAULT_IP_FILE = 'geoip.dat';
const LONG_FORMS: Array<[RegExp, string]> = [
[/^ext(?:-domain|-site)?:geosite\.dat:/, 'geosite:'],
[/^ext(?:-ip)?:geoip\.dat:/, 'geoip:'],
];
export function parseTokens(value: string): string[] {
return value
.split(',')
.map((token) => token.trim())
.filter((token) => token !== '');
}
export function formatTokens(tokens: string[]): string {
return tokens.join(', ');
}
export function tokenFor(file: string, code: string, kind: GeoKind): string {
if (kind === 'ip' && file === DEFAULT_IP_FILE) return `geoip:${code}`;
if (kind === 'site' && file === DEFAULT_SITE_FILE) return `geosite:${code}`;
return `ext:${file}:${code}`;
}
/**
* Xray treats category codes case-insensitively and accepts both the
* `geosite:cn` shorthand and its `ext:geosite.dat:cn` long form, so tokens are
* compared through this normal form. Only comparison uses it — whatever the
* user typed is what stays in the rule.
*/
export function canonicalToken(token: string): string {
const lowered = token.trim().toLowerCase();
for (const [pattern, shorthand] of LONG_FORMS) {
if (pattern.test(lowered)) return lowered.replace(pattern, shorthand);
}
return lowered;
}
export function selectionFromValue(value: string, known: ReadonlySet<string>): string[] {
const canonicalKnown = new Set([...known].map(canonicalToken));
const selection: string[] = [];
const seen = new Set<string>();
for (const token of parseTokens(value)) {
const canonical = canonicalToken(token);
if (!canonicalKnown.has(canonical) || seen.has(canonical)) continue;
seen.add(canonical);
selection.push(token);
}
return selection;
}
export function mergeSelection(
value: string,
selected: string[],
known: ReadonlySet<string>,
): string {
const canonicalKnown = new Set([...known].map(canonicalToken));
const kept = new Set(
selected.map((token) => canonicalToken(token)).filter((token) => token !== ''),
);
const merged: string[] = [];
const seen = new Set<string>();
const append = (token: string) => {
const canonical = canonicalToken(token);
if (canonical === '' || seen.has(canonical)) return;
seen.add(canonical);
merged.push(token);
};
for (const token of parseTokens(value)) {
const canonical = canonicalToken(token);
if (canonicalKnown.has(canonical) && !kept.has(canonical)) continue;
append(token);
}
for (const token of selected) {
append(token.trim());
}
return formatTokens(merged);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export class AllSetting {
tgLang = 'en-US';
twoFactorEnable = false;
twoFactorToken = '';
xrayTemplateConfig = '';
trafficStatsSwitches = '';
subEnable = true;
subJsonEnable = false;
subJsonAutoDetect = false;
@@ -1,4 +1,4 @@
import { MuxForm } from '@/pages/xray/outbounds/transport';
import { MuxForm } from '@/lib/xray/forms/outbound/transport';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers';
@@ -1,4 +1,4 @@
import { SockoptForm } from '@/pages/xray/outbounds/transport';
import { SockoptForm } from '@/lib/xray/forms/outbound/transport';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
+1 -4
View File
@@ -392,16 +392,13 @@ export function useInbounds() {
// Invalidate at the inbounds root so both `slim` (this page's list)
// and `options` (the Clients page's inbound picker) refetch. Without
// the options bucket, a freshly-created inbound stays invisible in
// the client add/edit modal until a full page reload. The xray config
// response carries inboundTags for the routing-rule tag picker, so it
// needs invalidating too or that list stays stale until a hard refresh.
// the client add/edit modal until a full page reload.
await Promise.all([
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
]);
}, [queryClient]);
+93 -30
View File
@@ -31,47 +31,34 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
useEffect(() => {
let cancelled = false;
(async () => {
// Candidates for the panel egress picker: template outbounds plus
// subscription-derived outbounds, and routing balancers. The panel egress
// is injected as a routing rule, so a balancer tag is a valid target
// (it load-balances the panel's own traffic). The geodata picker, by
// contrast, dials a forced tag and can only use a concrete outbound.
const msg = (await HttpUtil.post('/panel/api/xray/', undefined, {
// Candidates for the panel egress picker: the outbound directory
// (factory outbounds plus subscription-derived outbounds, and
// routing balancers). The panel egress is injected as a routing
// rule, so a balancer tag is a valid target (it load-balances the
// panel's own traffic).
const msg = (await HttpUtil.get('/panel/api/server/outboundDirectory', undefined, {
silent: true,
})) as ApiMsg<string>;
if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
try {
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
const template = (payload.xraySetting || {}) as Record<string, unknown>;
})) as ApiMsg<{
outbounds?: { tag?: string; protocol?: string }[];
balancers?: string[];
subscriptionOutboundTags?: string[];
}>;
if (cancelled || !msg?.success || !msg.obj) return;
const tags = new Set<string>();
const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
for (const o of outbounds) {
if (!o || typeof o !== 'object') continue;
const rec = o as Record<string, unknown>;
for (const rec of msg.obj.outbounds ?? []) {
if (!rec || typeof rec !== 'object') continue;
if (rec.protocol === 'blackhole') continue; // dropping traffic is never a useful egress
const tag = rec.tag;
if (typeof tag === 'string' && tag) tags.add(tag);
if (typeof rec.tag === 'string' && rec.tag) tags.add(rec.tag);
}
const subTags = Array.isArray(payload.subscriptionOutboundTags)
? payload.subscriptionOutboundTags
: [];
for (const tag of subTags) {
for (const tag of msg.obj.subscriptionOutboundTags ?? []) {
if (typeof tag === 'string' && tag) tags.add(tag);
}
const balancerTags: string[] = [];
const routing = (template.routing || {}) as Record<string, unknown>;
const balancers = Array.isArray(routing.balancers) ? routing.balancers : [];
for (const b of balancers) {
if (!b || typeof b !== 'object') continue;
const tag = (b as Record<string, unknown>).tag;
for (const tag of msg.obj.balancers ?? []) {
if (typeof tag === 'string' && tag && !tags.has(tag)) balancerTags.push(tag);
}
setOutboundTagList([...tags]);
setBalancerTagList(balancerTags);
} catch {
setOutboundTagList([]);
setBalancerTagList([]);
}
})();
return () => {
cancelled = true;
@@ -98,6 +85,38 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
];
}, [outboundTagList, balancerTagList, t]);
// The whole-network traffic accounting switches (settings key
// trafficStatsSwitches, ITEM6 §4.3.1): one JSON string carrying the
// four direction keys the sing-box generator's stats gate reads.
const statsSwitches = useMemo(() => {
try {
const parsed = JSON.parse(allSetting.trafficStatsSwitches || '') as Partial<{
inboundUplink: boolean;
inboundDownlink: boolean;
outboundUplink: boolean;
outboundDownlink: boolean;
}>;
return {
inboundUplink: parsed.inboundUplink !== false,
inboundDownlink: parsed.inboundDownlink !== false,
outboundUplink: parsed.outboundUplink === true,
outboundDownlink: parsed.outboundDownlink === true,
};
} catch {
return {
inboundUplink: true,
inboundDownlink: true,
outboundUplink: false,
outboundDownlink: false,
};
}
}, [allSetting.trafficStatsSwitches]);
const setStatsSwitch =
(key: 'inboundUplink' | 'inboundDownlink' | 'outboundUplink' | 'outboundDownlink') =>
(v: boolean) =>
updateSetting({ trafficStatsSwitches: JSON.stringify({ ...statsSwitches, [key]: v }) });
function onLangChange(value: string) {
setLang(value);
LanguageManager.setLanguage(value);
@@ -264,6 +283,50 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficStatsInboundUplink')}
description={t('pages.settings.trafficStatsDesc')}
>
<Switch
checked={statsSwitches.inboundUplink}
onChange={setStatsSwitch('inboundUplink')}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficStatsInboundDownlink')}
description={t('pages.settings.trafficStatsDesc')}
>
<Switch
checked={statsSwitches.inboundDownlink}
onChange={setStatsSwitch('inboundDownlink')}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficStatsOutboundUplink')}
description={t('pages.settings.trafficStatsDesc')}
>
<Switch
checked={statsSwitches.outboundUplink}
onChange={setStatsSwitch('outboundUplink')}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficStatsOutboundDownlink')}
description={t('pages.settings.trafficStatsDesc')}
>
<Switch
checked={statsSwitches.outboundDownlink}
onChange={setStatsSwitch('outboundDownlink')}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.language')}>
<Select
value={lang}
@@ -32,7 +32,7 @@ import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import type { SubBalancer, SubBalancerFormValues } from '@/schemas/subBalancer';
import { PingConfigSchema, type PingConfigObject } from '@/schemas/observatory';
import { DEFAULT_BURST_OBSERVATORY } from '@/pages/xray/balancers/balancer-helpers';
import { DEFAULT_BURST_OBSERVATORY } from '@/lib/xray/balancer-helpers';
import SubBalancerFormModal from './SubBalancerFormModal';
import { catTabLabel } from './catTabLabel';
import './SubscriptionFormatsTab.css';
@@ -1,40 +0,0 @@
import { useTranslation } from 'react-i18next';
import type { DeletionImpact } from './reference-cleanup';
interface DeletionImpactListProps {
impact: DeletionImpact;
}
export default function DeletionImpactList({ impact }: DeletionImpactListProps) {
const { t } = useTranslation();
const lines: string[] = [];
for (const rule of impact.rules) {
lines.push(
rule.fate === 'removed'
? t('pages.xray.refCleanup.ruleRemoved', { label: rule.label })
: t('pages.xray.refCleanup.ruleModified', { label: rule.label, keeps: rule.keeps ?? '' }),
);
}
for (const balancer of impact.balancers) {
lines.push(t('pages.xray.refCleanup.balancerRemoved', { tag: balancer.tag }));
}
if (impact.observatory) lines.push(t('pages.xray.observatory.deleteAlsoObservatory'));
if (impact.burst) lines.push(t('pages.xray.observatory.deleteAlsoBurst'));
if (lines.length === 0) return null;
return (
<div>
<p style={{ marginBottom: 8 }}>{t('pages.xray.refCleanup.header')}</p>
<ul style={{ margin: 0, paddingInlineStart: 20 }}>
{lines.map((line, i) => (
<li key={i}>
<bdi>{line}</bdi>
</li>
))}
</ul>
</div>
);
}
-8
View File
@@ -1,8 +0,0 @@
.xray-page .advanced-meta h4 {
margin: 0 0 4px;
}
.xray-page .advanced-meta p {
margin: 0;
opacity: 0.7;
}
-286
View File
@@ -1,286 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router';
import {
Alert,
Button,
Card,
Col,
ConfigProvider,
FloatButton,
Layout,
message,
Result,
Row,
Space,
Spin,
} from 'antd';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useXraySetting } from '@/hooks/useXraySetting';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import AppSidebar from '@/layouts/AppSidebar';
import { setMessageInstance } from '@/utils/messageBus';
import { BasicsTab } from './basics';
import { propagateOutboundTagRename } from './basics/helpers';
import { RoutingTab } from './routing';
import { OutboundsTab } from './outbounds';
import {
cleanupOrphanedBalancerLoopbacks,
ensureMissingBalancerLoopbacks,
detectBalancerCycles,
} from './balancers/balancer-loopback';
import { WarpModal, NordModal, PiaModal } from './overrides';
import './XrayPage.css';
const SECTION_SLUGS = ['basic', 'routing', 'outbound'];
export default function XrayPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const xs = useXraySetting();
const {
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
setXraySetting,
templateSettings,
setTemplateSettings,
inboundTags,
clientReverseTags,
subscriptionOutbounds,
subscriptionOutboundTags,
outboundsTraffic,
fetchAll,
resetOutboundsTraffic,
saveAll,
resetToDefault,
} = xs;
const [warpOpen, setWarpOpen] = useState(false);
const [nordOpen, setNordOpen] = useState(false);
const [piaOpen, setPiaOpen] = useState(false);
const location = useLocation();
const pathSection =
location.pathname === '/outbound'
? 'outbound'
: location.pathname === '/routing'
? 'routing'
: '';
const sectionSlug = pathSection || location.hash.replace(/^#/, '');
const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic';
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],
);
function onAddOutbound(outbound: Record<string, unknown>) {
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
tt.outbounds.push(outbound as never);
});
}
function onResetOutbound(payload: {
index: number;
outbound: Record<string, unknown>;
oldTag?: string;
newTag?: string;
}) {
mutate((tt) => {
if (!tt.outbounds || payload.index < 0) return;
tt.outbounds[payload.index] = payload.outbound as never;
if (payload.oldTag && payload.newTag) {
propagateOutboundTagRename(tt, payload.oldTag, payload.newTag);
}
});
}
function onRemoveOutboundByTag(tag: string) {
mutate((tt) => {
if (!tt.outbounds) return;
const idx = tt.outbounds.findIndex((o) => o?.tag === tag);
if (idx >= 0) tt.outbounds.splice(idx, 1);
});
}
function onRemoveOutboundByIndex(index: number) {
mutate((tt) => {
if (tt.outbounds && index >= 0) tt.outbounds.splice(index, 1);
});
}
function onRemoveRoutingRules(payload: { prefix: string }) {
mutate((tt) => {
const rules = tt.routing?.rules;
if (!Array.isArray(rules)) return;
tt.routing!.rules = rules.filter((r) => !r?.outboundTag?.startsWith?.(payload.prefix));
});
}
function onSaveAll() {
try {
JSON.parse(xraySetting);
} catch (e) {
messageApi.error(`Template JSON: ${(e as Error).message}`);
return;
}
if (templateSettings) {
const clone = JSON.parse(JSON.stringify(templateSettings));
ensureMissingBalancerLoopbacks(clone);
cleanupOrphanedBalancerLoopbacks(clone);
const cycles = detectBalancerCycles(clone);
if (cycles.length > 0) {
const names = cycles.map((c) => c.join(' → ')).join(', ');
messageApi.error(t('pages.xray.balancer.balancerFallbackCycle') + ' (' + names + ')');
return;
}
const serialized = JSON.stringify(clone, null, 2);
setXraySetting(serialized);
setTemplateSettings(clone);
}
saveAll();
}
const scrollTarget = () => document.getElementById('content-layout') || window;
const pageClass = `xray-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
const sectionBody = (() => {
switch (activeSection) {
case 'routing':
return (
<RoutingTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
inboundTags={inboundTags}
clientReverseTags={clientReverseTags}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
/>
);
case 'outbound':
return (
<OutboundsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
outboundsTraffic={outboundsTraffic}
inboundTags={inboundTags}
subscriptionOutbounds={subscriptionOutbounds}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
onResetTraffic={resetOutboundsTraffic}
onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)}
onShowPia={() => setPiaOpen(true)}
onRefreshXrayData={fetchAll}
/>
);
default:
return (
<BasicsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
onResetDefault={resetToDefault}
/>
);
}
})();
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin
spinning={spinning || !fetched}
delay={200}
description={t('loading')}
size="large"
>
{!fetched ? (
<div className="loading-spacer" />
) : fetchError ? (
<Result
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={
<Button type="primary" onClick={fetchAll}>
{t('check')}
</Button>
}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 0 : 12]}>
<Col span={24}>
<Card hoverable>
<Row className="header-row">
<Col xs={24} sm={14} className="header-actions">
<Space>
<Button type="primary" disabled={saveDisabled} onClick={onSaveAll}>
{t('pages.xray.save')}
</Button>
</Space>
</Col>
<Col xs={24} sm={10} className="header-info">
<FloatButton.BackTop target={scrollTarget} visibilityHeight={200} />
<Alert type="warning" showIcon title={t('pages.settings.infoDesc')} />
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card hoverable>{sectionBody}</Card>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<WarpModal
open={warpOpen}
templateSettings={templateSettings}
onClose={() => setWarpOpen(false)}
onAddOutbound={onAddOutbound}
onResetOutbound={onResetOutbound}
onRemoveOutbound={onRemoveOutboundByTag}
/>
<NordModal
open={nordOpen}
templateSettings={templateSettings}
onClose={() => setNordOpen(false)}
onAddOutbound={onAddOutbound}
onResetOutbound={onResetOutbound}
onRemoveOutbound={onRemoveOutboundByIndex}
onRemoveRoutingRules={onRemoveRoutingRules}
/>
<PiaModal
open={piaOpen}
templateSettings={templateSettings}
onClose={() => setPiaOpen(false)}
onAddOutbound={onAddOutbound}
onResetOutbound={onResetOutbound}
/>
</Layout>
</ConfigProvider>
);
}
@@ -1,229 +0,0 @@
import type { XraySettingsValue } from '@/hooks/useXraySetting';
const LOOPBACK_PREFIX = '_bl_';
export function isBalancerLoopbackTag(tag: string): boolean {
return tag.startsWith(LOOPBACK_PREFIX);
}
export function loopbackTagFor(targetBalancerTag: string): string {
return LOOPBACK_PREFIX + targetBalancerTag;
}
export function balancerTagFromLoopback(loopbackTag: string): string | null {
if (!isBalancerLoopbackTag(loopbackTag)) return null;
return loopbackTag.slice(LOOPBACK_PREFIX.length);
}
function loopbackMatchesTarget(loopbackTag: string, targetTag: string): boolean {
if (!isBalancerLoopbackTag(loopbackTag)) return false;
const target = balancerTagFromLoopback(loopbackTag);
return target === targetTag;
}
function findLoopbackTarget(settings: XraySettingsValue, loopbackTag: string): string | null {
const rules = (settings.routing?.rules || []) as Array<{
inboundTag?: string[];
balancerTag?: string;
}>;
for (const r of rules) {
if (Array.isArray(r.inboundTag) && r.inboundTag.includes(loopbackTag) && r.balancerTag) {
return r.balancerTag;
}
}
return null;
}
export function resolveLoopbackFallback(settings: XraySettingsValue, fallbackTag: string): string {
if (!fallbackTag || !isBalancerLoopbackTag(fallbackTag)) return fallbackTag;
const target = findLoopbackTarget(settings, fallbackTag);
if (target) return target;
const targetTag = balancerTagFromLoopback(fallbackTag);
return targetTag || fallbackTag;
}
function countLoopbackRefs(settings: XraySettingsValue, targetTag: string): number {
let count = 0;
for (const b of (settings.routing?.balancers || []) as Array<{ fallbackTag?: string }>) {
if (
b.fallbackTag &&
isBalancerLoopbackTag(b.fallbackTag) &&
loopbackMatchesTarget(b.fallbackTag, targetTag)
) {
count++;
}
}
return count;
}
function isGeneralRoutingRule(rule: Record<string, unknown>): boolean {
const inboundTag = rule.inboundTag;
return !Array.isArray(inboundTag) || inboundTag.length === 0;
}
function firstGeneralRoutingRuleIndex(rules: Array<Record<string, unknown>>): number {
for (let i = 0; i < rules.length; i += 1) {
if (isGeneralRoutingRule(rules[i])) return i;
}
return rules.length;
}
export function ensureBalancerLoopback(
settings: XraySettingsValue,
targetBalancerTag: string,
): void {
const lbTag = loopbackTagFor(targetBalancerTag);
if (!Array.isArray(settings.outbounds)) settings.outbounds = [];
const existingIdx = (settings.outbounds as Array<{ tag?: string; protocol?: string }>).findIndex(
(o) => o.tag === lbTag,
);
const newOutbound = { tag: lbTag, protocol: 'loopback', settings: { inboundTag: lbTag } };
if (existingIdx >= 0) {
(settings.outbounds as Record<string, unknown>[])[existingIdx] = newOutbound;
} else {
(settings.outbounds as Record<string, unknown>[]).push(newOutbound);
}
if (!settings.routing) settings.routing = { rules: [], balancers: [] };
if (!Array.isArray(settings.routing.rules)) settings.routing.rules = [];
const rules = settings.routing.rules as Array<Record<string, unknown>>;
const existingRuleIdx = rules.findIndex(
(r) => Array.isArray(r.inboundTag) && (r.inboundTag as string[]).includes(lbTag),
);
if (existingRuleIdx >= 0) {
const existing = rules[existingRuleIdx];
existing.balancerTag = targetBalancerTag;
rules.splice(existingRuleIdx, 1);
rules.splice(firstGeneralRoutingRuleIndex(rules), 0, existing);
} else {
rules.splice(firstGeneralRoutingRuleIndex(rules), 0, {
type: 'field',
inboundTag: [lbTag],
balancerTag: targetBalancerTag,
});
}
}
export function removeBalancerLoopbackIfOrphaned(
settings: XraySettingsValue,
targetBalancerTag: string,
): void {
if (countLoopbackRefs(settings, targetBalancerTag) > 0) return;
removeBalancerLoopback(settings, targetBalancerTag);
}
export function removeBalancerLoopback(
settings: XraySettingsValue,
targetBalancerTag: string,
): void {
const lbTag = loopbackTagFor(targetBalancerTag);
if (Array.isArray(settings.outbounds)) {
settings.outbounds = (settings.outbounds as Array<{ tag?: string }>).filter(
(o) => o.tag !== lbTag,
) as XraySettingsValue['outbounds'];
}
if (settings.routing && Array.isArray(settings.routing.rules)) {
settings.routing.rules = settings.routing.rules.filter(
(r) => !(Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag)),
);
}
}
export function propagateBalancerTagRename(
settings: XraySettingsValue,
oldTag: string,
newTag: string,
): void {
const oldLbTag = loopbackTagFor(oldTag);
const newLbTag = loopbackTagFor(newTag);
if (Array.isArray(settings.outbounds)) {
for (const o of settings.outbounds as Array<{
tag?: string;
settings?: { inboundTag?: string };
}>) {
if (o.tag === oldLbTag) o.tag = newLbTag;
if (o.settings?.inboundTag === oldLbTag) o.settings.inboundTag = newLbTag;
}
}
if (settings.routing && Array.isArray(settings.routing.rules)) {
for (const r of settings.routing.rules as Array<{ inboundTag?: string[] }>) {
if (Array.isArray(r.inboundTag)) {
const idx = r.inboundTag.indexOf(oldLbTag);
if (idx !== -1) r.inboundTag[idx] = newLbTag;
}
}
}
if (settings.routing && Array.isArray(settings.routing.balancers)) {
for (const b of settings.routing.balancers as Array<{ tag?: string; fallbackTag?: string }>) {
if (b.fallbackTag === oldLbTag) b.fallbackTag = newLbTag;
}
}
}
export function detectBalancerCycles(settings: XraySettingsValue): string[][] {
const balancers = (settings.routing?.balancers || []) as Array<{
tag?: string;
fallbackTag?: string;
}>;
const cycles: string[][] = [];
for (const b of balancers) {
if (!b.tag || !b.fallbackTag || !isBalancerLoopbackTag(b.fallbackTag)) continue;
const targetTag = balancerTagFromLoopback(b.fallbackTag);
if (!targetTag) continue;
const visited = new Set<string>();
let cursor = targetTag;
while (cursor && !visited.has(cursor)) {
if (cursor === b.tag) {
cycles.push([b.tag, targetTag]);
break;
}
visited.add(cursor);
const next = balancers.find((x) => x.tag === cursor);
const fb = next?.fallbackTag;
if (!fb || !isBalancerLoopbackTag(fb)) break;
cursor = balancerTagFromLoopback(fb) || '';
}
}
return cycles;
}
export function ensureMissingBalancerLoopbacks(settings: XraySettingsValue): void {
const balancers = (settings.routing?.balancers || []) as Array<{
tag?: string;
fallbackTag?: string;
}>;
for (const b of balancers) {
if (!b.fallbackTag || !isBalancerLoopbackTag(b.fallbackTag)) continue;
const targetTag = balancerTagFromLoopback(b.fallbackTag);
if (!targetTag) continue;
ensureBalancerLoopback(settings, targetTag);
}
}
export function cleanupOrphanedBalancerLoopbacks(settings: XraySettingsValue): void {
if (!Array.isArray(settings.outbounds)) return;
const orphanedTags: string[] = [];
for (const o of settings.outbounds as Array<{ tag?: string; protocol?: string }>) {
if (o.protocol !== 'loopback' || !o.tag || !isBalancerLoopbackTag(o.tag)) continue;
const targetTag = balancerTagFromLoopback(o.tag);
if (targetTag && countLoopbackRefs(settings, targetTag) === 0) {
orphanedTags.push(targetTag);
}
}
for (const tag of orphanedTags) {
removeBalancerLoopback(settings, tag);
}
}
@@ -1,3 +0,0 @@
.hint-alert {
text-align: center;
}
@@ -1,499 +0,0 @@
import { useCallback } from 'react';
import { onNumber } from '@/utils/onNumber';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
import {
BarChartOutlined,
ClockCircleOutlined,
FileTextOutlined,
ReloadOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives';
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './BasicsTab.css';
import {
ACCESS_LOG,
ERROR_LOG,
LOG_LEVELS,
MASK_ADDRESS,
ROUTING_DOMAIN_STRATEGIES,
} from './constants';
interface BasicsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
onResetDefault: () => void;
}
export default function BasicsTab({
templateSettings,
setTemplateSettings,
onResetDefault,
}: BasicsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
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 setLevel0 = useCallback(
(field: string, value: number | null) =>
mutate((tt) => {
if (!tt.policy) tt.policy = {};
if (!tt.policy.levels) tt.policy.levels = {};
if (!tt.policy.levels['0']) tt.policy.levels['0'] = {};
if (value === null || value === undefined) {
delete tt.policy.levels['0'][field];
} else {
tt.policy.levels['0'][field] = value;
}
}),
[mutate],
);
const metricsCfg = (templateSettings as { metrics?: { tag?: string; listen?: string } } | null)
?.metrics;
const setMetrics = useCallback(
(field: 'tag' | 'listen', value: string) =>
mutate((tt) => {
const node = tt as {
metrics?: { tag?: string; listen?: string };
stats?: Record<string, unknown>;
};
const m: { tag?: string; listen?: string } = { ...(node.metrics ?? {}) };
if (value.trim() === '') {
delete m[field];
} else {
m[field] = value.trim();
}
if (!m.listen && !m.tag) {
delete node.metrics;
} else {
node.metrics = m;
// xray-core's metrics handler needs a stats object to populate.
if (!node.stats) node.stats = {};
}
}),
[mutate],
);
function confirmResetDefault() {
modal.confirm({
title: t('pages.settings.resetDefaultConfig'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => onResetDefault(),
});
}
const freedomStrategy =
(
templateSettings?.outbounds?.find((o) => o?.protocol === 'freedom' && o?.tag === 'direct')
?.settings as { domainStrategy?: string } | undefined
)?.domainStrategy ?? 'AsIs';
const directFreedomOutbound = templateSettings?.outbounds?.find(
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
);
const directHappyEyeballs = (() => {
const sockopt = (
directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined
)?.sockopt;
const raw = sockopt?.happyEyeballs;
if (raw == null || typeof raw !== 'object') return null;
const parsed = HappyEyeballsSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
})();
const setDirectHappyEyeballs = useCallback(
(next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
mutate((tt) => {
if (!tt.outbounds) tt.outbounds = [];
let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
if (idx < 0) {
tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
idx = tt.outbounds.length - 1;
}
const ob = tt.outbounds[idx];
const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
if (next == null) {
delete sockopt.happyEyeballs;
} else {
sockopt.happyEyeballs = next;
}
if (Object.keys(sockopt).length === 0) {
delete stream.sockopt;
} else {
stream.sockopt = sockopt;
}
if (Object.keys(stream).length === 0) {
delete ob.streamSettings;
} else {
ob.streamSettings = stream;
}
});
},
[mutate],
);
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
const log = (templateSettings?.log || {}) as Record<string, unknown>;
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
const level0 = (templateSettings?.policy?.levels?.['0'] || {}) as Record<string, unknown>;
const items = [
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.generalConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.FreedomStrategy')}
description={t('pages.xray.FreedomStrategyDesc')}
paddings="small"
control={
<Select
value={freedomStrategy}
style={{ width: '100%' }}
options={OutboundDomainStrategies.map((s) => ({ value: s, label: s }))}
onChange={(next) =>
mutate((tt) => {
if (!tt.outbounds) tt.outbounds = [];
const idx = tt.outbounds.findIndex(
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
);
if (idx < 0) {
tt.outbounds.push({
protocol: 'freedom',
tag: 'direct',
settings: { domainStrategy: next },
});
} else {
const ob = tt.outbounds[idx];
ob.settings = (ob.settings || {}) as Record<string, unknown>;
(ob.settings as Record<string, unknown>).domainStrategy = next;
}
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.FreedomHappyEyeballs')}
description={t('pages.xray.FreedomHappyEyeballsDesc')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs != null}
onChange={(checked) => {
setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
}}
/>
}
/>
{directHappyEyeballs != null && (
<>
<SettingListItem
title={t('pages.inbounds.form.tryDelayMs')}
description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
paddings="small"
control={
<InputNumber
min={0}
style={{ width: '100%' }}
value={directHappyEyeballs.tryDelayMs}
placeholder="150"
onChange={onNumber((v) =>
setDirectHappyEyeballs({
...directHappyEyeballs,
tryDelayMs: v,
}),
)}
/>
}
/>
<SettingListItem
title={t('pages.inbounds.form.prioritizeIPv6')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs.prioritizeIPv6}
onChange={(checked) =>
setDirectHappyEyeballs({
...directHappyEyeballs,
prioritizeIPv6: checked,
})
}
/>
}
/>
</>
)}
<SettingListItem
title={t('pages.xray.RoutingStrategy')}
description={t('pages.xray.RoutingStrategyDesc')}
paddings="small"
control={
<Select
value={routingStrategy}
style={{ width: '100%' }}
options={ROUTING_DOMAIN_STRATEGIES.map((s) => ({ value: s, label: s }))}
onChange={(next) =>
mutate((tt) => {
if (tt.routing) tt.routing.domainStrategy = next;
})
}
/>
}
/>
</>
),
},
{
key: '2',
label: catTabLabel(<BarChartOutlined />, t('pages.xray.statistics'), isMobile),
children: (
<>
{[
['statsInboundUplink', t('pages.xray.statsInboundUplink')],
['statsInboundDownlink', t('pages.xray.statsInboundDownlink')],
['statsOutboundUplink', t('pages.xray.statsOutboundUplink')],
['statsOutboundDownlink', t('pages.xray.statsOutboundDownlink')],
].map(([field, label]) => (
<SettingListItem
key={field}
title={label}
paddings="small"
control={
<Switch
checked={!!policy[field]}
onChange={(checked) =>
mutate((tt) => {
if (!tt.policy) tt.policy = {};
if (!tt.policy.system) tt.policy.system = {};
tt.policy.system[field] = checked;
})
}
/>
}
/>
))}
<SettingListItem
title={t('pages.xray.metricsListen')}
description={t('pages.xray.metricsListenDesc')}
paddings="small"
control={
<Input
value={metricsCfg?.listen ?? ''}
onChange={(e) => setMetrics('listen', e.target.value)}
placeholder="127.0.0.1:11111"
/>
}
/>
<SettingListItem
title={t('pages.xray.metricsTag')}
paddings="small"
control={
<Input
value={metricsCfg?.tag ?? ''}
onChange={(e) => setMetrics('tag', e.target.value)}
placeholder="metrics_out"
/>
}
/>
</>
),
},
{
key: 'connection',
label: catTabLabel(<ClockCircleOutlined />, t('pages.xray.connectionLimits'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.connectionLimitsDesc')}
/>
<SettingListItem
title={t('pages.xray.connIdle')}
description={t('pages.xray.connIdleDesc')}
paddings="small"
control={
<InputNumber
value={typeof level0.connIdle === 'number' ? level0.connIdle : undefined}
min={0}
style={{ width: '100%' }}
placeholder="300"
suffix={t('pages.xray.seconds')}
onChange={(v) => setLevel0('connIdle', v as number | null)}
/>
}
/>
<SettingListItem
title={t('pages.xray.bufferSize')}
description={t('pages.xray.bufferSizeDesc')}
paddings="small"
control={
<InputNumber
value={typeof level0.bufferSize === 'number' ? level0.bufferSize : undefined}
min={0}
style={{ width: '100%' }}
placeholder={t('pages.xray.bufferSizePlaceholder')}
suffix="KB"
onChange={(v) => setLevel0('bufferSize', v as number | null)}
/>
}
/>
</>
),
},
{
key: '3',
label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile),
children: (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.logConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.logLevel')}
description={t('pages.xray.logLevelDesc')}
paddings="small"
control={
<Select
value={(log.loglevel as string) || 'warning'}
style={{ width: '100%' }}
options={LOG_LEVELS.map((s) => ({ value: s, label: s }))}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.loglevel = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.accessLog')}
description={t('pages.xray.accessLogDesc')}
paddings="small"
control={
<Select
value={(log.access as string) || ''}
style={{ width: '100%' }}
options={ACCESS_LOG.map((s) => ({ value: s, label: s }))}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.access = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.errorLog')}
description={t('pages.xray.errorLogDesc')}
paddings="small"
control={
<Select
value={(log.error as string) || ''}
style={{ width: '100%' }}
options={[
{ value: '', label: t('empty') },
...ERROR_LOG.map((s) => ({ value: s, label: s })),
]}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.error = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.maskAddress')}
description={t('pages.xray.maskAddressDesc')}
paddings="small"
control={
<Select
value={(log.maskAddress as string) || ''}
style={{ width: '100%' }}
options={[
{ value: '', label: t('empty') },
...MASK_ADDRESS.map((s) => ({ value: s, label: s })),
]}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.maskAddress = v;
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.dnsLog')}
description={t('pages.xray.dnsLogDesc')}
paddings="small"
control={
<Switch
checked={!!log.dnsLog}
onChange={(v) =>
mutate((tt) => {
if (tt.log) tt.log.dnsLog = v;
})
}
/>
}
/>
</>
),
},
{
key: 'reset',
label: catTabLabel(<ReloadOutlined />, t('pages.settings.resetDefaultConfig'), isMobile),
children: (
<Space style={{ padding: '0 20px' }}>
<Button type="primary" danger icon={<ReloadOutlined />} onClick={confirmResetDefault}>
{t('pages.settings.resetDefaultConfig')}
</Button>
</Space>
),
},
];
return (
<>
{modalContextHolder}
<Tabs defaultActiveKey="1" items={items} />
</>
);
}
@@ -1,68 +0,0 @@
export const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
export const LOG_LEVELS = ['none', 'debug', 'info', 'warning', 'error'];
export const ACCESS_LOG = ['none', './access.log'];
export const ERROR_LOG = ['none', './error.log'];
export const MASK_ADDRESS = ['quarter', 'half', 'full'];
export const BITTORRENT_PROTOCOLS = ['bittorrent'];
export const IPS_OPTIONS = [
{ label: 'Private IPs', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
{ label: '🇪🇸 Spain', value: 'geoip:es' },
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
];
export const DOMAINS_OPTIONS = [
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
export const BLOCK_DOMAINS_OPTIONS = [
{ label: 'Ads All', value: 'geosite:category-ads-all' },
{ label: 'Ads IR 🇮🇷', value: 'ext:geosite_IR.dat:category-ads-all' },
{ label: 'Ads RU 🇷🇺', value: 'ext:geosite_RU.dat:category-ads-all' },
{ label: 'Malware 🇮🇷', value: 'ext:geosite_IR.dat:malware' },
{ label: 'Phishing 🇮🇷', value: 'ext:geosite_IR.dat:phishing' },
{ label: 'Cryptominers 🇮🇷', value: 'ext:geosite_IR.dat:cryptominers' },
{ label: 'Adult +18', value: 'geosite:category-porn' },
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
export const SERVICES_OPTIONS = [
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
{ label: 'OpenAI', value: 'geosite:openai' },
{ label: 'Spotify', value: 'geosite:spotify' },
{ label: 'Netflix', value: 'geosite:netflix' },
{ label: 'Reddit', value: 'geosite:reddit' },
{ label: 'Speedtest', value: 'geosite:speedtest' },
];
export const directSettings = { tag: 'direct', protocol: 'freedom' };
export const blockedSettings = { tag: 'blocked', protocol: 'blackhole', settings: {} };
export const ipv4Settings = {
tag: 'IPv4',
protocol: 'freedom',
settings: { domainStrategy: 'UseIPv4' },
};
-121
View File
@@ -1,121 +0,0 @@
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { blockedSettings, directSettings } from './constants';
export function ruleGetter(
t: XraySettingsValue | null,
outboundTag: string,
property: string,
): string[] {
if (!t?.routing?.rules) return [];
const out: string[] = [];
for (const rule of t.routing.rules) {
if (
rule &&
Object.prototype.hasOwnProperty.call(rule, property) &&
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
rule.outboundTag === outboundTag
) {
const v = (rule as Record<string, unknown>)[property];
if (Array.isArray(v)) out.push(...(v as string[]));
}
}
return out;
}
export function ruleSetter(
t: XraySettingsValue,
outboundTag: string,
property: string,
data: string[],
): void {
if (!t.routing) return;
if (!Array.isArray(t.routing.rules)) t.routing.rules = [];
const current = ruleGetter(t, outboundTag, property);
if (current.length === 0) {
t.routing.rules.push({ type: 'field', outboundTag, [property]: data });
return;
}
const next: typeof t.routing.rules = [];
let inserted = false;
for (const rule of t.routing.rules) {
const matches =
rule &&
Object.prototype.hasOwnProperty.call(rule, property) &&
Object.prototype.hasOwnProperty.call(rule, 'outboundTag') &&
rule.outboundTag === outboundTag;
if (matches) {
if (!inserted && data.length > 0) {
(rule as Record<string, unknown>)[property] = data;
next.push(rule);
inserted = true;
}
} else {
next.push(rule);
}
}
t.routing.rules = next;
}
export function syncOutbound(t: XraySettingsValue, tag: string, settings: Record<string, unknown>) {
if (!t.outbounds || !t.routing) return;
const rules = t.routing.rules || [];
const haveRules = rules.some((r) => r?.outboundTag === tag);
const idx = t.outbounds.findIndex((o) => o?.tag === tag);
if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
if (haveRules && idx < 0) t.outbounds.push(settings as never);
}
export function getDefaultOutboundTag(t: XraySettingsValue | null): string {
const tag = t?.outbounds?.[0]?.tag;
return typeof tag === 'string' && tag.length > 0 ? tag : 'direct';
}
export function setDefaultOutboundTag(t: XraySettingsValue, tag: string): void {
if (!tag) return;
if (!Array.isArray(t.outbounds)) t.outbounds = [];
const idx = t.outbounds.findIndex((o) => o?.tag === tag);
if (idx < 0) {
if (tag === 'direct') t.outbounds.push(directSettings as never);
else if (tag === 'blocked') t.outbounds.push(blockedSettings as never);
else return;
const newIdx = t.outbounds.length - 1;
const [moved] = t.outbounds.splice(newIdx, 1);
t.outbounds.unshift(moved);
} else if (idx > 0) {
const [moved] = t.outbounds.splice(idx, 1);
t.outbounds.unshift(moved);
}
}
export function propagateOutboundTagRename(
t: XraySettingsValue,
oldTag: string,
newTag: string,
): void {
if (!oldTag || !newTag || oldTag === newTag) return;
const rules = t.routing?.rules;
if (Array.isArray(rules)) {
for (const rule of rules) {
if (rule?.outboundTag === oldTag) rule.outboundTag = newTag;
}
}
const balancers = t.routing?.balancers;
if (Array.isArray(balancers)) {
for (const balancer of balancers) {
if (balancer?.fallbackTag === oldTag) balancer.fallbackTag = newTag;
if (Array.isArray(balancer?.selector)) {
balancer.selector = balancer.selector.map((sel) => (sel === oldTag ? newTag : sel));
}
}
}
if (Array.isArray(t.outbounds)) {
for (const outbound of t.outbounds) {
const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
?.streamSettings?.sockopt;
if (sockopt?.dialerProxy === oldTag) sockopt.dialerProxy = newTag;
}
}
}
-1
View File
@@ -1 +0,0 @@
export { default as BasicsTab } from './BasicsTab';
@@ -1,141 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag, Tooltip } from 'antd';
import {
RetweetOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
VerticalAlignTopOutlined,
ExportOutlined,
} from '@ant-design/icons';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
interface OutboundCardListProps {
rows: OutboundRow[];
outboundsTraffic: OutboundTrafficRow[];
setFirst: (idx: number) => void;
openEdit: (idx: number) => void;
onResetTraffic: (tag: string) => void;
confirmDelete: (idx: number) => void;
}
export default function OutboundCardList({
rows,
outboundsTraffic,
setFirst,
openEdit,
onResetTraffic,
confirmDelete,
}: OutboundCardListProps) {
const { t } = useTranslation();
if (rows.length === 0) {
return (
<div className="card-empty">
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} aria-hidden="true" />
<div>{t('noData')}</div>
</div>
);
}
return (
<>
{rows.map((record, index) => (
<div key={record.key} className="outbound-card">
<div className="card-head">
<div className="card-identity">
<span className="card-num">{index + 1}</span>
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag}</span>
</Tooltip>
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(
record.protocol as never,
) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && (
<Tag color="purple">{record.streamSettings?.security}</Tag>
)}
</>
)}
</div>
<Dropdown
trigger={['click']}
menu={{
items: [
...(index > 0
? [
{
key: 'top',
label: (
<>
<VerticalAlignTopOutlined /> {t('pages.xray.outbound.moveToTop')}
</>
),
onClick: () => setFirst(index),
},
]
: []),
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEdit(index),
},
{
key: 'reset',
label: (
<>
<RetweetOutlined /> {t('pages.inbounds.resetTraffic')}
</>
),
onClick: () => onResetTraffic(record.tag || ''),
},
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => confirmDelete(index),
},
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
{outboundAddresses(record).length > 0 && (
<div className="address-list">
{outboundAddresses(record).map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))}
</div>
)}
<div className="card-foot">
<span className="traffic-up">
↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}
</span>
<span className="traffic-sep" />
<span className="traffic-down">
↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}
</span>
</div>
</div>
))}
</>
);
}
@@ -1,18 +0,0 @@
.item-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-weight: 500;
opacity: 0.85;
}
.sniffing-options {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
.sniffing-options .ant-checkbox-wrapper {
margin-inline-start: 0;
}
@@ -1,598 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Radio, Select, Space, Tabs, message } from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import { FinalMaskField, SniffingField } from '@/lib/xray/forms/fields';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { JsonEditor } from '@/components/form';
import { Wireguard } from '@/utils';
import { formValuesToWirePayload, rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
import { OutboundFormBaseSchema, type OutboundFormValues } from '@/schemas/forms/outbound-form';
import {
canEnableReality,
canEnableStream,
canEnableTls,
canEnableTlsFlow,
} from '@/lib/xray/protocol-capabilities';
import {
FLOW_OPTIONS,
HYSTERIA_NETWORK_OPTION,
NETWORK_OPTIONS,
PROTOCOL_OPTIONS,
SERVER_PROTOCOLS,
TARGET_STRATEGY_OPTIONS,
} from './outbound-form-constants';
import {
applyNetworkChange,
buildAddModeValues,
hysteriaStreamSlice,
newStreamSlice,
} from './outbound-form-helpers';
import {
BlackholeFields,
DnsFields,
FreedomFields,
HttpFields,
LoopbackFields,
ServerTarget,
ShadowsocksFields,
SocksFields,
TrojanFields,
VlessFields,
VmessFields,
WireguardFields,
} from './protocols';
import {
GrpcForm,
HttpUpgradeForm,
HysteriaForm,
KcpForm,
MuxForm,
RawForm,
SockoptForm,
WsForm,
XhttpForm,
} from './transport';
import { RealityForm, TlsForm } from './security';
import './OutboundFormModal.css';
type StreamValue = OutboundFormValues['streamSettings'];
interface OutboundFormModalProps {
open: boolean;
outbound: Record<string, unknown> | null;
existingTags: string[];
dialerProxyTags?: string[];
onClose: () => void;
onConfirm: (outbound: Record<string, unknown>) => void;
}
export default function OutboundFormModal({
open,
outbound: outboundProp,
existingTags,
dialerProxyTags,
onClose,
onConfirm,
}: OutboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const methods = useForm<OutboundFormValues>({ defaultValues: buildAddModeValues() });
const [activeKey, setActiveKey] = useState('1');
const [jsonText, setJsonText] = useState('');
const [jsonDirty, setJsonDirty] = useState(false);
const [linkInput, setLinkInput] = useState('');
const isEdit = outboundProp != null;
const title = isEdit
? `${t('edit')} ${t('pages.xray.Outbounds')}`
: `+ ${t('pages.xray.Outbounds')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
const tag = (useWatch({ control: methods.control, name: 'tag' }) ?? '') as string;
const protocol = (useWatch({ control: methods.control, name: 'protocol' }) ?? 'vless') as string;
const network = (useWatch({ control: methods.control, name: 'streamSettings.network' }) ??
'') as string;
const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ??
'none') as string;
const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string;
const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' });
const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as
| string
| undefined;
const streamAllowed = canEnableStream({ protocol });
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
/*
* Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
* hysteria2:// / wireguard://) and replace form state with the result.
* The current tag is preserved when the parsed link doesn't carry one.
*/
function importLink() {
const link = linkInput.trim();
if (!link) return;
const parsed = parseOutboundLink(link);
if (!parsed) {
messageApi.error('Wrong Link!');
return;
}
const currentTag = methods.getValues('tag');
if (!parsed.tag && currentTag) parsed.tag = currentTag;
const next = rawOutboundToFormValues(parsed);
methods.reset(next);
setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
setJsonDirty(false);
setLinkInput('');
messageApi.success('Link imported successfully');
switchTab('1');
}
useEffect(() => {
if (!open) return;
const initial = outboundProp ? rawOutboundToFormValues(outboundProp) : buildAddModeValues();
methods.reset(initial);
setActiveKey('1');
setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
setJsonDirty(false);
}, [open, outboundProp, methods]);
useEffect(() => {
if (!streamAllowed) return;
/*
* Wireguard dials its own UDP — only finalmask/sockopt apply, never a
* transport. Don't seed network 'tcp'; clear a leftover one (from a
* protocol switch) so the transmission/security blocks stay hidden.
*/
if (protocol === 'wireguard') {
if (network) methods.setValue('streamSettings', { security: 'none' } as StreamValue);
return;
}
if (network) return;
methods.setValue('streamSettings', {
...newStreamSlice('tcp'),
security: 'none',
} as StreamValue);
}, [streamAllowed, network, protocol, methods]);
useEffect(() => {
if (protocol !== 'hysteria') return;
if (network === 'hysteria' && security === 'tls') return;
const existing = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
const slice = hysteriaStreamSlice();
if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
methods.setValue('streamSettings', slice as StreamValue);
}, [protocol, network, security, methods]);
useEffect(() => {
if (protocol !== 'wireguard') return;
const sk = (wgSecretKey ?? '').trim();
if (!sk) {
methods.setValue('settings.pubKey', '');
return;
}
try {
const { publicKey } = Wireguard.generateKeypair(sk);
methods.setValue('settings.pubKey', publicKey);
} catch {
methods.setValue('settings.pubKey', '');
}
}, [protocol, wgSecretKey, methods]);
useEffect(() => {
/* eslint-disable-next-line react-hooks/incompatible-library */
const sub = methods.watch((_value, { name, type }) => {
if (name !== 'protocol' || type !== 'change') return;
const nextProtocol = methods.getValues('protocol');
const next = rawOutboundToFormValues({ protocol: nextProtocol });
methods.setValue('settings', next.settings);
if (nextProtocol === 'hysteria') {
methods.setValue('streamSettings', hysteriaStreamSlice() as StreamValue);
} else if ((methods.getValues('streamSettings.network') ?? '') === 'hysteria') {
methods.setValue('streamSettings', {
...newStreamSlice('tcp'),
security: 'none',
} as StreamValue);
}
});
return () => sub.unsubscribe();
}, [methods]);
function onSecurityChange(next: string) {
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
const cleaned = { ...stream };
delete cleaned.tlsSettings;
delete cleaned.realitySettings;
if (next === 'tls') {
cleaned.tlsSettings = {
serverName: '',
alpn: [],
fingerprint: '',
echConfigList: '',
verifyPeerCertByName: '',
pinnedPeerCertSha256: '',
};
} else if (next === 'reality') {
cleaned.realitySettings = {
publicKey: '',
fingerprint: 'chrome',
serverName: '',
shortId: '',
spiderX: '',
mldsa65Verify: '',
};
}
cleaned.security = next;
methods.setValue('streamSettings', cleaned as StreamValue);
}
/*
* Network change cascade: swap the per-network sub-key (tcpSettings,
* wsSettings, etc.) so the DU branch matches. Preserve security if
* the new network supports it, otherwise force back to 'none'.
*/
function onNetworkChange(next: string) {
const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue);
}
function onXmuxToggle(checked: boolean) {
if (!checked) return;
const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
if (hasValues) return;
methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS });
}
const duplicateTag = useMemo(() => {
const myTag = tag.trim();
if (!myTag) return false;
if (isEdit && (outboundProp?.tag as string | undefined) === myTag) return false;
return (existingTags || []).includes(myTag);
}, [tag, existingTags, isEdit, outboundProp]);
/*
* Bridge form <-> JSON tab: when leaving the JSON tab back to Basic, push
* any edits into form state. When entering JSON tab, snapshot current
* form values so the user sees the live shape.
*/
function applyJsonToForm(): boolean {
if (!jsonDirty) return true;
const raw = jsonText.trim();
if (!raw) return true;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (e) {
messageApi.error(`JSON: ${(e as Error).message}`);
return false;
}
const next = rawOutboundToFormValues(parsed);
methods.reset(next);
setJsonDirty(false);
return true;
}
function switchTab(key: string) {
if (typeof document !== 'undefined') {
(document.activeElement as HTMLElement | null)?.blur?.();
}
setActiveKey(key);
}
function onTabChange(key: string) {
if (key === '2') {
const values = methods.getValues();
setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
setJsonDirty(false);
switchTab(key);
return;
}
if (key === '1' && activeKey === '2') {
if (!applyJsonToForm()) return;
}
switchTab(key);
}
async function onOk() {
let values: OutboundFormValues;
if (activeKey === '2') {
const raw = jsonText.trim();
if (!raw) return;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch (e) {
messageApi.error(`JSON: ${(e as Error).message}`);
return;
}
values = rawOutboundToFormValues(parsed);
methods.reset(values);
setJsonDirty(false);
} else {
if (!(await methods.trigger())) return;
values = methods.getValues();
}
const tagValue = (values.tag ?? '').trim();
if (!tagValue) {
messageApi.error(t('pages.xray.outboundForm.tagRequired'));
return;
}
if (tagValue.startsWith('_bl_')) {
messageApi.error(t('pages.xray.balancer.reservedPrefix'));
return;
}
const isDuplicateTag =
(existingTags || []).includes(tagValue) &&
!(isEdit && (outboundProp?.tag as string | undefined) === tagValue);
if (isDuplicateTag) {
messageApi.error('Tag already used by another outbound');
return;
}
onConfirm(formValuesToWirePayload(values));
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
mask={{ closable: false }}
width={780}
onOk={onOk}
onCancel={onClose}
destroyOnHidden
>
<FormProvider {...methods}>
<Form
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
labelWrap
>
<Tabs
activeKey={activeKey}
onChange={onTabChange}
items={[
{
key: '1',
label: t('pages.xray.basicTemplate'),
children: (
<>
<FormField
label={t('protocol')}
name="protocol"
rules={{ validate: rhfZodValidate(OutboundFormBaseSchema.shape.tag) }}
>
<Select id="protocol" options={PROTOCOL_OPTIONS} />
</FormField>
<Controller
control={methods.control}
name="tag"
rules={{ required: 'pages.xray.outboundForm.tagRequired' }}
render={({ field, fieldState }) => {
const errorMessage = fieldState.error?.message
? t(fieldState.error.message, {
defaultValue: fieldState.error.message,
})
: '';
return (
<Form.Item
label={t('pages.xray.outbound.tag')}
required
validateStatus={
errorMessage ? 'error' : duplicateTag ? 'warning' : undefined
}
help={
errorMessage ||
(duplicateTag
? t('pages.xray.outboundForm.tagDuplicate')
: undefined)
}
>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
ref={field.ref}
placeholder={t('pages.xray.outboundForm.tagPlaceholder')}
/>
</Form.Item>
);
}}
/>
<FormField label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
<Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
</FormField>
<FormField
label={t('pages.xray.outbound.targetStrategy')}
name="targetStrategy"
tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
>
<Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
</FormField>
{SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
{protocol === 'vmess' && <VmessFields />}
{protocol === 'vless' && <VlessFields />}
{protocol === 'trojan' && <TrojanFields />}
{protocol === 'shadowsocks' && <ShadowsocksFields />}
{protocol === 'http' && <HttpFields />}
{protocol === 'socks' && <SocksFields />}
{protocol === 'loopback' && <LoopbackFields />}
{protocol === 'blackhole' && <BlackholeFields />}
{protocol === 'dns' && <DnsFields />}
{protocol === 'freedom' && <FreedomFields />}
{protocol === 'vless' && reverseTag && (
<Controller
control={methods.control}
name="settings.reverseSniffing"
render={({ field }) => (
<SniffingField
value={field.value}
onChange={field.onChange}
enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
/>
)}
/>
)}
{protocol === 'wireguard' && <WireguardFields />}
{streamAllowed && network && (
<>
<Form.Item label={t('transmission')}>
<Select
value={network}
onChange={onNetworkChange}
options={
protocol === 'hysteria'
? [HYSTERIA_NETWORK_OPTION]
: NETWORK_OPTIONS
}
/>
</Form.Item>
{network === 'tcp' && <RawForm />}
{network === 'kcp' && <KcpForm />}
{network === 'ws' && <WsForm />}
{network === 'grpc' && <GrpcForm />}
{network === 'httpupgrade' && <HttpUpgradeForm />}
{network === 'xhttp' && <XhttpForm onXmuxToggle={onXmuxToggle} />}
{network === 'hysteria' && <HysteriaForm />}
</>
)}
{tlsFlowAllowed && (
<FormField label={t('pages.clients.flow')} name={['settings', 'flow']}>
<Select
allowClear
placeholder={t('none')}
options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
/>
</FormField>
)}
{/* Vision seed knobs only meaningful for the exact
xtls-rprx-vision flow, on TCP+(tls|reality). */}
{tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
<>
<FormField
label={t('pages.xray.outboundForm.visionTestpre')}
name={['settings', 'testpre']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Form.Item label={t('pages.inbounds.form.visionTestseed')}>
<Space.Compact block>
{[0, 1, 2, 3].map((i) => (
<FormField key={i} name={['settings', 'testseed', i]} noStyle>
<InputNumber min={1} style={{ width: '25%' }} />
</FormField>
))}
</Space.Compact>
</Form.Item>
</>
)}
{streamAllowed && network && (
<Form.Item label={t('security')}>
<Radio.Group
value={security}
buttonStyle="solid"
onChange={(e) => onSecurityChange(e.target.value as string)}
>
{network !== 'hysteria' && (
<Radio.Button value="none">{t('none')}</Radio.Button>
)}
{tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
{realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
</Radio.Group>
</Form.Item>
)}
{security === 'tls' && tlsAllowed && <TlsForm />}
{security === 'reality' && realityAllowed && <RealityForm />}
{((streamAllowed && network) ||
!streamAllowed ||
protocol === 'wireguard') && (
<SockoptForm outboundTags={dialerProxyTags ?? existingTags} />
)}
<Controller
control={methods.control}
name="streamSettings.finalmask"
render={({ field }) => (
<FinalMaskField
key={`${protocol}:${network}`}
value={field.value}
onChange={field.onChange}
network={network}
protocol={protocol}
/>
)}
/>
<MuxForm protocol={protocol} network={network} />
</>
),
},
{
key: '2',
label: 'JSON',
children: (
<Space
orientation="vertical"
size={10}
style={{ width: '100%', marginTop: 10 }}
>
<Input.Search
value={linkInput}
placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
enterButton="Import"
onChange={(e) => setLinkInput(e.target.value)}
onSearch={importLink}
/>
<JsonEditor
value={jsonText}
onChange={(next) => {
setJsonText(next);
setJsonDirty(true);
}}
minHeight="360px"
maxHeight="600px"
/>
</Space>
),
},
]}
/>
</Form>
</FormProvider>
</Modal>
</>
);
}
@@ -1,276 +0,0 @@
.toolbar-right {
display: flex;
justify-content: flex-end;
}
/* Keep this in sync with the other pages' .card-empty (it's a global class):
the previous opacity:0.4 here leaked onto whichever page's empty state was
shown after the Outbounds CSS loaded, fading it. */
.card-empty {
text-align: center;
color: var(--ant-color-text-secondary);
padding: 24px 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.outbound-card {
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.card-identity {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.card-num {
font-weight: 500;
opacity: 0.7;
min-width: 18px;
text-align: right;
}
.tag-name {
font-weight: 500;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.protocol-line {
display: inline-flex;
flex-wrap: wrap;
gap: 2px;
}
.address-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.address-pill {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--ant-color-fill-tertiary);
}
.warp-cloud-icon {
width: 1em;
height: 1em;
color: #f48120;
flex: 0 0 auto;
}
.warp-cloud-icon svg {
fill: #f48120;
}
.ip-toggle-icon {
cursor: pointer;
opacity: 0.65;
}
.ip-toggle-icon:hover {
opacity: 1;
}
.address-hidden {
filter: blur(5px);
transition: filter 0.2s ease;
}
.address-visible {
filter: none;
}
.action-cell {
display: flex;
align-items: center;
gap: 6px;
}
.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;
}
.identity-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.card-foot {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.traffic-up {
color: #008771;
font-size: 12px;
}
.traffic-down {
color: #3c89e8;
font-size: 12px;
}
.traffic-sep {
display: inline-block;
width: 4px;
}
.pill-ok,
.pill-fail {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 12px;
font-size: 12px;
}
.pill-ok {
color: #008771;
background: rgba(0, 135, 113, 0.12);
}
.pill-fail {
color: #e04141;
background: rgba(224, 65, 65, 0.12);
}
.empty {
opacity: 0.4;
}
.outbound-test-popover .timing-breakdown {
font-size: 12px;
line-height: 1.6;
min-width: 180px;
max-width: 320px;
}
.outbound-test-popover .td-head {
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.outbound-test-popover .td-head.ok {
color: #008771;
}
.outbound-test-popover .td-head.fail {
color: #e04141;
}
.outbound-test-popover .mode-badge {
font-size: 10px;
font-weight: 500;
padding: 0 6px;
border-radius: 8px;
background: color-mix(in srgb, var(--ant-color-primary) 12%, transparent);
color: var(--ant-color-primary);
margin-left: auto;
}
.outbound-test-popover .endpoint-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
white-space: nowrap;
}
.outbound-test-popover .endpoint-row .ep-addr {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.outbound-test-popover .endpoint-row .ep-meta {
opacity: 0.75;
}
.outbound-test-popover .dot-ok {
color: #008771;
}
.outbound-test-popover .dot-fail {
color: #e04141;
}
.outbound-test-popover .breakdown-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
white-space: nowrap;
}
.outbound-test-popover .breakdown-row .bd-label {
flex: 1;
min-width: 0;
opacity: 0.85;
}
.outbound-test-popover .breakdown-row .bd-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
opacity: 0.75;
}
.subscription-outbounds-head {
margin-bottom: 8px;
}
.subscription-outbounds-title {
font-weight: 600;
margin-bottom: 2px;
}
.subscription-outbounds-desc {
font-size: 12px;
opacity: 0.7;
}
@@ -1,935 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Col,
Dropdown,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Space,
Switch,
Table,
Tag,
Tooltip,
message,
} from 'antd';
import {
PlusOutlined,
CloudOutlined,
ApiOutlined,
MoreOutlined,
RetweetOutlined,
ReloadOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
CheckCircleOutlined,
WarningOutlined,
ExportOutlined,
ImportOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import PromptModal from '@/components/feedback/PromptModal';
import TextModal from '@/components/feedback/TextModal';
import OutboundFormModal from './OutboundFormModal';
import { propagateOutboundTagRename } from '../basics/helpers';
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
import DeletionImpactList from '../DeletionImpactList';
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
import type { XraySettingsValue, SetTemplate, OutboundTrafficRow } from '@/hooks/useXraySetting';
import './OutboundsTab.css';
import type { OutboundRow } from './outbounds-tab-types';
import { originalOutboundIndex } from './outbounds-tab-helpers';
import { useOutboundColumns } from './useOutboundColumns';
import OutboundCardList from './OutboundCardList';
import SubscriptionOutbounds from './SubscriptionOutbounds';
interface OutboundSub {
id: number;
remark?: string;
url?: string;
enabled?: boolean;
allowPrivate?: boolean;
allowInsecure?: boolean;
prepend?: boolean;
priority?: number;
tagPrefix?: string;
updateInterval?: number;
lastUpdated?: number;
lastError?: string;
outboundCount?: number;
}
interface OutboundsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
outboundsTraffic: OutboundTrafficRow[];
inboundTags: string[];
subscriptionOutbounds?: unknown[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
onResetTraffic: (tag: string) => void;
onShowWarp: () => void;
onShowNord: () => void;
onShowPia: () => void;
onRefreshXrayData?: () => void;
}
export default function OutboundsTab({
templateSettings,
setTemplateSettings,
outboundsTraffic,
inboundTags: _inboundTags,
subscriptionOutbounds,
subscriptionOutboundTags,
isMobile,
onResetTraffic,
onShowWarp,
onShowNord,
onShowPia,
onRefreshXrayData,
}: OutboundsTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [modalOpen, setModalOpen] = useState(false);
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [existingTags, setExistingTags] = useState<string[]>([]);
// Subscription manager (CRUD + reorder + refresh + preview)
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
const [subs, setSubs] = useState<OutboundSub[]>([]);
const [subsLoading, setSubsLoading] = useState(false);
const [newSub, setNewSub] = useState({
remark: '',
url: '',
tagPrefix: '',
updateInterval: 600,
enabled: true,
allowPrivate: false,
allowInsecure: false,
prepend: false,
});
const [editingSubId, setEditingSubId] = useState<number | null>(null);
const [savingSub, setSavingSub] = useState(false);
const [refreshingId, setRefreshingId] = useState<number | null>(null);
const [refreshingAll, setRefreshingAll] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [previewing, setPreviewing] = useState(false);
const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(
null,
);
// Convenience: expose hours/minutes for the interval input
const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600);
const intervalMinutes = Math.floor(((newSub.updateInterval || 600) % 3600) / 60);
function setIntervalHM(h: number, m: number) {
const secs = Math.max(60, (h || 0) * 3600 + (m || 0) * 60);
setNewSub((prev) => ({ ...prev, updateInterval: secs }));
}
const outbounds = useMemo(
() => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
[templateSettings?.outbounds],
);
const rows = useMemo(
() =>
outbounds
.map((o, i) => ({ ...o, key: i }))
.filter((o) => !isBalancerLoopbackTag(o.tag || '')),
[outbounds],
);
const rowsRef = useRef<OutboundRow[]>([]);
rowsRef.current = rows;
const dialerProxyTags = useMemo(() => {
const tags = new Set<string>();
(templateSettings?.outbounds || []).forEach((o, i) => {
if (i === editingIndex) return;
if (o?.protocol === 'blackhole') return;
if (o?.tag) tags.add(o.tag);
});
for (const tag of subscriptionOutboundTags || []) {
if (tag) tags.add(tag);
}
return [...tags];
}, [templateSettings?.outbounds, editingIndex, subscriptionOutboundTags]);
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],
);
function openAdd() {
setEditingOutbound(null);
setEditingIndex(null);
setExistingTags(
(templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg),
);
setModalOpen(true);
}
function openSubManager() {
setSubDrawerOpen(true);
loadSubs();
}
function openEdit(idx: number) {
const target = originalOutboundIndex(rowsRef.current, idx);
setEditingOutbound((templateSettings?.outbounds || [])[target] as Record<string, unknown>);
setEditingIndex(target);
setExistingTags(
(templateSettings?.outbounds || [])
.filter((_, i) => i !== target)
.map((o) => o?.tag)
.filter((tg): tg is string => !!tg),
);
setModalOpen(true);
}
function onConfirm(outbound: Record<string, unknown>) {
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
const newTag = typeof outbound.tag === 'string' ? outbound.tag : '';
if (editingIndex == null) {
if (!newTag) return;
tt.outbounds.push(outbound as never);
} else {
const oldTag = tt.outbounds[editingIndex]?.tag;
tt.outbounds[editingIndex] = outbound as never;
if (oldTag && newTag && oldTag !== newTag) {
propagateOutboundTagRename(tt, oldTag, newTag);
}
}
});
setModalOpen(false);
}
function confirmDelete(idx: number) {
const target = originalOutboundIndex(rowsRef.current, idx);
const impact = templateSettings
? planOutboundDeletion(templateSettings, target)
: { rules: [], balancers: [], observatory: false, burst: false };
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
content: <DeletionImpactList impact={impact} />,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => mutate((tt) => applyOutboundDeletion(tt, target)),
});
}
function setFirst(idx: number) {
const target = originalOutboundIndex(rowsRef.current, idx);
mutate((tt) => {
if (!tt.outbounds) return;
const [moved] = tt.outbounds.splice(target, 1);
tt.outbounds.unshift(moved);
});
}
function moveUp(idx: number) {
if (idx <= 0) return;
const target = originalOutboundIndex(rowsRef.current, idx);
const prev = originalOutboundIndex(rowsRef.current, idx - 1);
mutate((tt) => {
if (!tt.outbounds) return;
[tt.outbounds[prev], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[prev]];
});
}
function moveDown(idx: number) {
if (idx >= rowsRef.current.length - 1) return;
const target = originalOutboundIndex(rowsRef.current, idx);
const next = originalOutboundIndex(rowsRef.current, idx + 1);
mutate((tt) => {
if (!tt.outbounds) return;
[tt.outbounds[next], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[next]];
});
}
const [importOpen, setImportOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [exportContent, setExportContent] = useState('');
function exportOutbounds() {
setExportContent(JSON.stringify(outbounds, null, 2));
setExportOpen(true);
}
function importOutbounds(value: string) {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
messageApi.error(t('pages.xray.importInvalidJson'));
return;
}
const obj = parsed as { outbounds?: unknown };
const list = Array.isArray(parsed)
? parsed
: Array.isArray(obj?.outbounds)
? obj.outbounds
: null;
if (!list) {
messageApi.error(t('pages.xray.importInvalidJson'));
return;
}
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
tt.outbounds.push(...(list as never[]));
});
setImportOpen(false);
}
// --- Subscription management (minimal inline UI) ---
async function loadSubs() {
setSubsLoading(true);
try {
const r = await HttpUtil.get('/panel/api/xray/outbound-subs');
if (r?.success) setSubs(Array.isArray(r.obj) ? r.obj : []);
} catch {
messageApi.error(t('pages.xray.outboundSub.toastLoadFailed'));
} finally {
setSubsLoading(false);
}
}
function subBody(src: {
remark?: string;
url?: string;
tagPrefix?: string;
updateInterval?: number;
enabled?: boolean;
allowPrivate?: boolean;
allowInsecure?: boolean;
prepend?: boolean;
}) {
return {
remark: src.remark ?? '',
url: src.url ?? '',
tagPrefix: src.tagPrefix ?? '',
updateInterval: src.updateInterval ?? 600,
enabled: src.enabled ?? true,
allowPrivate: src.allowPrivate ?? false,
allowInsecure: src.allowInsecure ?? false,
prepend: src.prepend ?? false,
};
}
function resetSubForm() {
setNewSub({
remark: '',
url: '',
tagPrefix: '',
updateInterval: 600,
enabled: true,
allowPrivate: false,
allowInsecure: false,
prepend: false,
});
setEditingSubId(null);
setPreviewData(null);
}
function openEditSub(sub: OutboundSub) {
setNewSub({
remark: sub.remark ?? '',
url: sub.url ?? '',
tagPrefix: sub.tagPrefix ?? '',
updateInterval: sub.updateInterval ?? 600,
enabled: sub.enabled ?? true,
allowPrivate: sub.allowPrivate ?? false,
allowInsecure: sub.allowInsecure ?? false,
prepend: sub.prepend ?? false,
});
setEditingSubId(sub.id);
setPreviewData(null);
}
async function saveSub() {
if (!newSub.url.trim()) {
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
return;
}
setSavingSub(true);
try {
const url =
editingSubId != null
? `/panel/api/xray/outbound-subs/${editingSubId}`
: '/panel/api/xray/outbound-subs';
const r = await HttpUtil.post<OutboundSub>(url, subBody(newSub));
if (r?.success) {
messageApi.success(
t(
editingSubId != null
? 'pages.xray.outboundSub.toastUpdated'
: 'pages.xray.outboundSub.toastAdded',
),
);
const createdId = editingSubId == null ? r.obj?.id : undefined;
resetSubForm();
await loadSubs();
if (createdId) await refreshOne(createdId);
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
} finally {
setSavingSub(false);
}
}
async function previewSub() {
if (!newSub.url.trim()) {
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
return;
}
setPreviewing(true);
setPreviewData(null);
try {
const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>(
'/panel/api/xray/outbound-subs/parse',
{ url: newSub.url, allowPrivate: newSub.allowPrivate },
);
if (r?.success && Array.isArray(r.obj)) {
setPreviewData(r.obj);
if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty'));
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.previewEmpty'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.previewEmpty'));
} finally {
setPreviewing(false);
}
}
async function toggleEnabled(sub: OutboundSub) {
setBusyId(sub.id);
try {
const r = await HttpUtil.post(
`/panel/api/xray/outbound-subs/${sub.id}`,
subBody({ ...sub, enabled: !sub.enabled }),
);
if (r?.success) {
await loadSubs();
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
} finally {
setBusyId(null);
}
}
async function moveSub(id: number, dir: 'up' | 'down') {
setBusyId(id);
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/move`, { dir });
if (r?.success) {
await loadSubs();
onRefreshXrayData?.();
}
} catch {
/* ignore */
} finally {
setBusyId(null);
}
}
async function refreshOne(id: number) {
setRefreshingId(id);
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/refresh`);
if (r?.success) {
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
await loadSubs();
onRefreshXrayData?.();
} else {
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastRefreshFailed'));
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastRefreshFailed'));
} finally {
setRefreshingId(null);
}
}
async function refreshAllSubs() {
if (subs.length === 0) return;
setRefreshingAll(true);
try {
for (const s of subs) {
try {
await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`);
} catch {
/* continue */
}
}
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
await loadSubs();
onRefreshXrayData?.();
} finally {
setRefreshingAll(false);
}
}
async function deleteOne(id: number) {
try {
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/del`);
if (r?.success) {
messageApi.success(t('pages.xray.outboundSub.toastDeleted'));
await loadSubs();
onRefreshXrayData?.();
}
} catch {
messageApi.error(t('pages.xray.outboundSub.toastDeleteFailed'));
}
}
const columns = useOutboundColumns({
rows,
outboundsTraffic,
openEdit,
setFirst,
moveUp,
moveDown,
confirmDelete,
onResetTraffic,
});
return (
<>
{modalContextHolder}
{messageContextHolder}
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Row gutter={[12, 12]} align="middle" justify="space-between">
<Col xs={24} sm={12}>
<Space size="small" wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{!isMobile && t('pages.xray.Outbounds')}
</Button>
<Button icon={<CloudOutlined />} onClick={openSubManager}>
{t('pages.xray.outboundSub.manage')}
</Button>
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
{
key: 'pia',
icon: <ApiOutlined />,
label: t('pages.xray.pia.menu'),
onClick: onShowPia,
},
{ type: 'divider' },
{
key: 'import',
icon: <ImportOutlined />,
label: t('pages.xray.importOutbounds'),
onClick: () => setImportOpen(true),
},
{
key: 'export',
icon: <ExportOutlined />,
label: t('pages.xray.exportOutbounds'),
disabled: outbounds.length === 0,
onClick: exportOutbounds,
},
],
}}
>
<Button icon={<MoreOutlined />}>{t('more')}</Button>
</Dropdown>
</Space>
</Col>
<Col xs={24} sm={12} className="toolbar-right">
<Space size="small" wrap>
<Popconfirm
placement="topRight"
okText={t('reset')}
cancelText={t('cancel')}
title={t('pages.inbounds.resetAllTrafficContent')}
onConfirm={() => onResetTraffic('-alltags-')}
>
<Button aria-label={t('pages.inbounds.resetTraffic')} icon={<RetweetOutlined />} />
</Popconfirm>
</Space>
</Col>
</Row>
{isMobile ? (
<OutboundCardList
rows={rows}
outboundsTraffic={outboundsTraffic}
setFirst={setFirst}
openEdit={openEdit}
onResetTraffic={onResetTraffic}
confirmDelete={confirmDelete}
/>
) : (
<Table
columns={columns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
size="small"
locale={{
emptyText: (
<div className="card-empty">
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
/>
)}
<OutboundFormModal
open={modalOpen}
outbound={editingOutbound}
existingTags={existingTags}
dialerProxyTags={dialerProxyTags}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
<PromptModal
open={importOpen}
onClose={() => setImportOpen(false)}
title={t('pages.xray.importOutbounds')}
okText={t('pages.xray.importOutbounds')}
type="textarea"
json
onConfirm={importOutbounds}
/>
<TextModal
open={exportOpen}
onClose={() => 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 && (
<SubscriptionOutbounds
subscriptionOutbounds={subscriptionOutbounds}
outboundsTraffic={outboundsTraffic}
isMobile={isMobile}
/>
)}
</Space>
<Modal
title={t('pages.xray.outboundSub.title')}
open={subDrawerOpen}
onCancel={() => setSubDrawerOpen(false)}
footer={null}
width={isMobile ? '100%' : 640}
destroyOnHidden
>
<Space orientation="vertical" style={{ width: '100%' }} size="large">
<div>
{editingSubId != null && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag color="blue">{t('edit')}</Tag>
<span style={{ fontWeight: 600 }}>{newSub.remark || newSub.url}</span>
</div>
)}
<Form layout="vertical" size="small">
<Form.Item label={t('pages.xray.outboundSub.remark')}>
<Input
value={newSub.remark}
onChange={(e) => setNewSub({ ...newSub, remark: e.target.value })}
placeholder={t('pages.xray.outboundSub.remarkPlaceholder')}
/>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.url')} required>
<Input
value={newSub.url}
onChange={(e) => setNewSub({ ...newSub, url: e.target.value })}
placeholder={t('pages.xray.outboundSub.urlPlaceholder')}
/>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.tagPrefix')}>
<Input
value={newSub.tagPrefix}
onChange={(e) => setNewSub({ ...newSub, tagPrefix: e.target.value })}
placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')}
/>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.interval')}>
<Space>
<InputNumber
min={0}
value={intervalHours}
onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
style={{ width: 80 }}
/>{' '}
{t('pages.xray.outboundSub.hours')}
<InputNumber
min={0}
max={59}
value={intervalMinutes}
onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
style={{ width: 80 }}
/>{' '}
{t('pages.xray.outboundSub.minutes')}
</Space>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.intervalHint')}
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.enabled')}>
<Switch
checked={newSub.enabled}
onChange={(v) => setNewSub({ ...newSub, enabled: v })}
/>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.allowPrivate')}>
<Switch
checked={newSub.allowPrivate}
onChange={(v) => setNewSub({ ...newSub, allowPrivate: v })}
/>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.allowPrivateHint')}
</div>
</Form.Item>
<Form.Item label={t('pages.hosts.fields.allowInsecure')}>
<Switch
checked={newSub.allowInsecure}
onChange={(v) => setNewSub({ ...newSub, allowInsecure: v })}
/>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.hosts.hints.allowInsecure')}
</div>
</Form.Item>
<Form.Item label={t('pages.xray.outboundSub.prepend')}>
<Switch
checked={newSub.prepend}
onChange={(v) => setNewSub({ ...newSub, prepend: v })}
/>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{t('pages.xray.outboundSub.prependHint')}
</div>
</Form.Item>
<Space wrap>
<Button
type="primary"
onClick={saveSub}
loading={savingSub}
icon={editingSubId != null ? <EditOutlined /> : <PlusOutlined />}
>
{editingSubId != null ? t('save') : t('pages.xray.outboundSub.addButton')}
</Button>
<Button onClick={previewSub} loading={previewing} icon={<EyeOutlined />}>
{t('pages.xray.outboundSub.preview')}
</Button>
{editingSubId != null && <Button onClick={resetSubForm}>{t('cancel')}</Button>}
</Space>
{previewData && previewData.length > 0 && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>
{previewData.length} · {t('pages.xray.Outbounds')}
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 4,
maxHeight: 120,
overflow: 'auto',
}}
>
{previewData.map((o, i) => (
<Tag key={i}>
{o?.tag || '—'}
{o?.protocol ? ` · ${o.protocol}` : ''}
</Tag>
))}
</div>
</div>
)}
</Form>
</div>
<div>
<div
style={{
fontWeight: 600,
marginBottom: 8,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
{t('pages.xray.outboundSub.active')}
<Button
aria-label={t('refresh')}
size="small"
icon={<ReloadOutlined />}
onClick={loadSubs}
loading={subsLoading}
/>
{subs.length > 0 && (
<Button
size="small"
type="primary"
icon={<ReloadOutlined />}
onClick={refreshAllSubs}
loading={refreshingAll}
>
{t('pages.xray.outboundSub.refreshAll')}
</Button>
)}
</div>
{subs.length === 0 ? (
<div style={{ color: '#888' }}>{t('pages.xray.outboundSub.empty')}</div>
) : (
<Table
size="small"
dataSource={subs}
rowKey={(r) => r.id}
pagination={false}
scroll={{ x: true }}
columns={[
{
title: '',
key: 'order',
width: 56,
render: (_: unknown, r: OutboundSub, index: number) => (
<Space size={0}>
<Button
aria-label={t('pages.inbounds.form.moveUp')}
type="text"
size="small"
icon={<ArrowUpOutlined />}
disabled={index === 0 || busyId === r.id}
onClick={() => moveSub(r.id, 'up')}
/>
<Button
aria-label={t('pages.inbounds.form.moveDown')}
type="text"
size="small"
icon={<ArrowDownOutlined />}
disabled={index === subs.length - 1 || busyId === r.id}
onClick={() => moveSub(r.id, 'down')}
/>
</Space>
),
},
{
title: t('pages.xray.outboundSub.colRemark'),
key: 'remark',
render: (_: unknown, r: OutboundSub) => (
<div>
<div>{r.remark || <em>{t('pages.xray.outboundSub.auto')}</em>}</div>
{r.tagPrefix && (
<div style={{ fontSize: 11, color: '#888' }}>{r.tagPrefix}</div>
)}
</div>
),
},
{
title: t('pages.xray.Outbounds'),
dataIndex: 'outboundCount',
key: 'outboundCount',
align: 'center',
render: (v) => v ?? 0,
},
{
title: t('status'),
key: 'status',
align: 'center',
render: (_: unknown, r: OutboundSub) =>
r.lastError ? (
<Tooltip title={r.lastError}>
<WarningOutlined style={{ color: '#e04141' }} />
</Tooltip>
) : (
<Tooltip title={t('pages.xray.outboundSub.statusOk')}>
<CheckCircleOutlined style={{ color: '#008771' }} />
</Tooltip>
),
},
{
title: t('pages.xray.outboundSub.colLastFetch'),
dataIndex: 'lastUpdated',
key: 'lastUpdated',
render: (v: number) =>
v ? new Date(v * 1000).toLocaleString() : t('pages.xray.outboundSub.never'),
},
{
title: t('pages.xray.outboundSub.colEnabled'),
key: 'enabled',
align: 'center',
render: (_: unknown, r: OutboundSub) => (
<Switch
size="small"
checked={!!r.enabled}
loading={busyId === r.id}
onChange={() => toggleEnabled(r)}
/>
),
},
{
title: '',
key: 'actions',
render: (_: unknown, r: OutboundSub) => (
<Space>
<Button
aria-label={t('edit')}
size="small"
icon={<EditOutlined />}
onClick={() => openEditSub(r)}
title={t('edit')}
/>
<Button
aria-label={t('pages.xray.outboundSub.refreshNow')}
size="small"
icon={<ReloadOutlined />}
loading={refreshingId === r.id}
onClick={() => refreshOne(r.id)}
title={t('pages.xray.outboundSub.refreshNow')}
/>
<Popconfirm
title={t('pages.xray.outboundSub.deleteConfirm')}
okText={t('delete')}
cancelText={t('cancel')}
onConfirm={() => deleteOne(r.id)}
>
<Button
aria-label={t('delete')}
size="small"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm>
</Space>
),
},
]}
/>
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
{t('pages.xray.outboundSub.restartHint')}
</div>
</div>
</Space>
</Modal>
</>
);
}
@@ -1,156 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Table, Tag, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
interface SubscriptionOutboundsProps {
subscriptionOutbounds: unknown[];
outboundsTraffic: OutboundTrafficRow[];
isMobile: boolean;
}
// Read-only view of outbounds imported from active subscriptions. They are not
// part of the editable template (so no edit/delete/move), but traffic is matched
// by tag.
export default function SubscriptionOutbounds({
subscriptionOutbounds,
outboundsTraffic,
isMobile,
}: SubscriptionOutboundsProps) {
const { t } = useTranslation();
const rows = useMemo<OutboundRow[]>(
() =>
(subscriptionOutbounds || []).map((o, i) => ({ ...(o as object), key: i }) as OutboundRow),
[subscriptionOutbounds],
);
if (rows.length === 0) return null;
const identityCell = (record: OutboundRow) => (
<div className="identity-cell">
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag || '—'}</span>
</Tooltip>
<div className="protocol-line">
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(
record.protocol as never,
) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && (
<Tag color="purple">{record.streamSettings?.security}</Tag>
)}
</>
)}
</div>
</div>
);
const addressCell = (record: OutboundRow) => {
const addrs = outboundAddresses(record);
return (
<div className="address-list">
{addrs.length === 0 ? (
<span className="empty">—</span>
) : (
addrs.map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))
)}
</div>
);
};
const trafficCell = (record: OutboundRow) => {
const tr = trafficFor(outboundsTraffic, record);
return (
<>
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
<span className="traffic-sep" />
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
</>
);
};
const header = (
<div className="subscription-outbounds-head">
<div className="subscription-outbounds-title">
{t('pages.xray.outboundSub.fromSubsTitle')}
</div>
<div className="subscription-outbounds-desc">{t('pages.xray.outboundSub.fromSubsDesc')}</div>
</div>
);
if (isMobile) {
return (
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
{header}
{rows.map((record, index) => (
<div key={record.key} className="outbound-card">
<div className="card-head">
<div className="card-identity">
<span className="card-num">{index + 1}</span>
{identityCell(record)}
</div>
</div>
{outboundAddresses(record).length > 0 && addressCell(record)}
<div className="card-foot">{trafficCell(record)}</div>
</div>
))}
</div>
);
}
const columns: ColumnsType<OutboundRow> = [
{
title: '#',
key: 'num',
align: 'center',
width: 60,
render: (_v, _record, index) => <span className="row-index">{index + 1}</span>,
},
{
title: t('pages.xray.outbound.tag'),
key: 'identity',
align: 'left',
render: (_v, record) => identityCell(record),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => addressCell(record),
},
{
title: t('pages.inbounds.traffic'),
key: 'traffic',
align: 'left',
width: 200,
render: (_v, record) => trafficCell(record),
},
];
return (
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
{header}
<Table
columns={columns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
size="small"
/>
</div>
);
}
@@ -1,2 +0,0 @@
export { default as OutboundsTab } from './OutboundsTab';
export { default as OutboundFormModal } from './OutboundFormModal';
@@ -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<string, unknown> | 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;
}
}
@@ -1,7 +0,0 @@
export interface OutboundRow {
key: number;
tag?: string;
protocol?: string;
streamSettings?: { network?: string; security?: string };
settings?: Record<string, unknown>;
}
@@ -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 (
<FormField label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
<Select
options={[
{ value: '', label: '(empty)' },
{ value: 'none', label: 'none' },
{ value: 'http', label: 'http' },
]}
/>
</FormField>
);
}
@@ -1,84 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
import { activateOnKey } from '@/utils/a11y';
import { DNSRuleActions } from '@/schemas/primitives';
export default function DnsFields() {
const { t } = useTranslation();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({ control, name: 'settings.rules' });
return (
<>
<FormField
label={t('pages.xray.outboundForm.rewriteNetwork')}
name={['settings', 'rewriteNetwork']}
>
<Select
allowClear
placeholder={t('pages.xray.outboundForm.unchanged')}
options={[
{ value: 'udp', label: 'udp' },
{ value: 'tcp', label: 'tcp' },
]}
/>
</FormField>
<FormField
label={t('pages.inbounds.form.rewriteAddress')}
name={['settings', 'rewriteAddress']}
>
<Input placeholder={t('pages.xray.outboundForm.unchangedAddress')} />
</FormField>
<FormField label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
</FormField>
<FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<Form.Item label={t('pages.xray.outboundForm.rules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => append({ action: 'direct', qType: '', domain: '', rCode: 0 })}
/>
</Form.Item>
{fields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(index)}
onKeyDown={activateOnKey(() => remove(index))}
/>
</div>
</Form.Item>
<FormField
label={t('pages.xray.outboundForm.action')}
name={['settings', 'rules', index, 'action']}
>
<Select options={DNSRuleActions.map((a) => ({ value: a, label: a }))} />
</FormField>
<FormField label="QType" name={['settings', 'rules', index, 'qType']}>
<Input placeholder="1,3,23-24" />
</FormField>
<FormField label={t('domainName')} name={['settings', 'rules', index, 'domain']}>
<Input placeholder="domain:example.com" />
</FormField>
<FormField label="RCode" name={['settings', 'rules', index, 'rCode']}>
<InputNumber min={0} max={65535} style={{ width: '100%' }} />
</FormField>
</div>
))}
</>
);
}
@@ -1,289 +0,0 @@
import { useTranslation } from 'react-i18next';
import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
import { activateOnKey } from '@/utils/a11y';
import { OutboundDomainStrategies } from '@/schemas/primitives';
interface FragmentValue {
packets?: string;
length?: string;
interval?: string;
maxSplit?: string;
}
export default function FreedomFields() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const fragment = (useWatch({ control, name: 'settings.fragment' }) ?? {}) as FragmentValue;
const fragmentEnabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
const {
fields: noiseFields,
append: appendNoise,
remove: removeNoise,
} = useFieldArray({ control, name: 'settings.noises' });
const {
fields: finalRuleFields,
append: appendFinalRule,
remove: removeFinalRule,
} = useFieldArray({ control, name: 'settings.finalRules' });
const finalRulesValues = (useWatch({ control, name: 'settings.finalRules' }) ?? []) as {
action?: string;
}[];
return (
<>
<FormField
label={t('pages.xray.balancer.balancerStrategy')}
name={['settings', 'domainStrategy']}
>
<Select
options={[
{ value: '', label: `(${t('none')})` },
...OutboundDomainStrategies.map((s) => ({ value: s, label: s })),
]}
/>
</FormField>
<FormField label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
<Input />
</FormField>
<FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
<FormField
label={t('pages.xray.outboundForm.proxyProtocol')}
name={['settings', 'proxyProtocol']}
>
<Select
options={[
{ value: 0, label: `(${t('none')})` },
{ value: 1, label: 'v1' },
{ value: 2, label: 'v2' },
]}
/>
</FormField>
<Form.Item label="Fragment">
<Switch
checked={fragmentEnabled}
onChange={(checked) => {
setValue(
'settings.fragment',
checked
? {
packets: 'tlshello',
length: '100-200',
interval: '10-20',
maxSplit: '300-400',
}
: { packets: '', length: '', interval: '', maxSplit: '' },
);
}}
/>
</Form.Item>
{fragmentEnabled && (
<>
<FormField
label={t('pages.settings.subFormats.packets')}
name={['settings', 'fragment', 'packets']}
rules={{
validate: (value) => {
const str = String(value ?? '').trim();
/* xray accepts "tlshello" or any packet-number range (#5075) */
if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) return true;
return 'Use "tlshello" or a packet range like 1-3';
},
}}
>
<AutoComplete
options={[
{ value: 'tlshello', label: 'tlshello' },
{ value: '1-3', label: '1-3' },
{ value: '1-5', label: '1-5' },
]}
placeholder="tlshello or n-m, e.g. 1-3"
/>
</FormField>
<FormField
label={t('pages.settings.subFormats.length')}
name={['settings', 'fragment', 'length']}
>
<Input />
</FormField>
<FormField
label={t('pages.settings.subFormats.interval')}
name={['settings', 'fragment', 'interval']}
>
<Input />
</FormField>
<FormField
label={t('pages.settings.subFormats.maxSplit')}
name={['settings', 'fragment', 'maxSplit']}
>
<Input />
</FormField>
</>
)}
<Form.Item label={t('pages.settings.subFormats.noises')}>
<Switch
checked={noiseFields.length > 0}
onChange={(checked) => {
if (checked) {
appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' });
} else {
removeNoise();
}
}}
/>
{noiseFields.length > 0 && (
<Button
size="small"
type="primary"
className="ml-8"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })
}
/>
)}
</Form.Item>
{noiseFields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
{noiseFields.length > 1 && (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removeNoise(index)}
onKeyDown={activateOnKey(() => removeNoise(index))}
/>
)}
</div>
</Form.Item>
<FormField
label={t('pages.settings.subFormats.type')}
name={['settings', 'noises', index, 'type']}
>
<Select
options={['rand', 'base64', 'str', 'hex'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField
label={t('pages.settings.subFormats.packet')}
name={['settings', 'noises', index, 'packet']}
>
<Input />
</FormField>
<FormField
label={t('pages.settings.subFormats.delayMs')}
name={['settings', 'noises', index, 'delay']}
>
<Input />
</FormField>
<FormField
label={t('pages.settings.subFormats.applyTo')}
name={['settings', 'noises', index, 'applyTo']}
>
<Select
options={['ip', 'ipv4', 'ipv6'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
</div>
))}
<Form.Item label={t('pages.xray.outboundForm.finalRules')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })
}
/>
<span className="ml-8" style={{ opacity: 0.6 }}>
{t('pages.xray.outboundForm.overrideXrayPrivateIp')}
</span>
</Form.Item>
{finalRuleFields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removeFinalRule(index)}
onKeyDown={activateOnKey(() => removeFinalRule(index))}
/>
</div>
</Form.Item>
<FormField
label={t('pages.xray.outboundForm.action')}
name={['settings', 'finalRules', index, 'action']}
>
<Select
options={['allow', 'block'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField
label={t('pages.inbounds.network')}
name={['settings', 'finalRules', index, 'network']}
>
<Select
allowClear
placeholder="(any)"
options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
value: v,
label: v,
}))}
/>
</FormField>
<FormField
label={t('pages.inbounds.port')}
name={['settings', 'finalRules', index, 'port']}
>
<Input placeholder="e.g. 80,443 or 1000-2000" />
</FormField>
<FormField label="IP / CIDR / geoip" name={['settings', 'finalRules', index, 'ip']}>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
placeholder="e.g. 10.0.0.0/8, geoip:private"
/>
</FormField>
{finalRulesValues[index]?.action === 'block' && (
<FormField
label={t('pages.xray.outboundForm.blockDelay')}
name={['settings', 'finalRules', index, 'blockDelay']}
>
<Input placeholder="optional: 5000-10000" />
</FormField>
)}
</div>
))}
</>
);
}
@@ -1,22 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function HttpFields() {
const { t } = useTranslation();
return (
<>
<FormField label={t('username')} name={['settings', 'user']}>
<Input />
</FormField>
<FormField label={t('password')} name={['settings', 'pass']}>
<Input />
</FormField>
<FormField label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
<HeaderMapEditor mode="v1" />
</FormField>
</>
);
}
@@ -1,12 +0,0 @@
export { default as ServerTarget } from './server-target';
export { default as VmessFields } from './vmess';
export { default as VlessFields } from './vless';
export { default as TrojanFields } from './trojan';
export { default as ShadowsocksFields } from './shadowsocks';
export { default as HttpFields } from './http';
export { default as SocksFields } from './socks';
export { default as WireguardFields } from './wireguard';
export { default as FreedomFields } from './freedom';
export { default as LoopbackFields } from './loopback';
export { default as BlackholeFields } from './blackhole';
export { default as DnsFields } from './dns';
@@ -1,31 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input } from 'antd';
import { Controller, useFormContext } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
import { SniffingField } from '@/lib/xray/forms/fields';
export default function LoopbackFields() {
const { t } = useTranslation();
const { control } = useFormContext();
return (
<>
<FormField label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
<Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
</FormField>
<Controller
control={control}
name="settings.sniffing"
render={({ field }) => (
<SniffingField
value={field.value}
onChange={field.onChange}
enableLabel={t('pages.inbounds.sniffingTab')}
/>
)}
/>
</>
);
}
@@ -1,28 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input, InputNumber } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function ServerTarget() {
const { t } = useTranslation();
return (
<>
<FormField
label={t('pages.inbounds.address')}
name={['settings', 'address']}
required
rules={{ required: 'pages.xray.outboundForm.addressRequired' }}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.port')}
name={['settings', 'port']}
required
rules={{ required: 'pages.xray.outboundForm.portRequired' }}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</FormField>
</>
);
}
@@ -1,40 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch } from 'antd';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { ShadowsocksOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
import { SS_METHOD_OPTIONS } from '../outbound-form-constants';
export default function ShadowsocksFields() {
const { t } = useTranslation();
return (
<>
<FormField
label={t('password')}
name={['settings', 'password']}
rules={{ validate: rhfZodValidate(ShadowsocksOutboundFormSettingsSchema.shape.password) }}
>
<Input />
</FormField>
<FormField
label={t('encryption')}
name={['settings', 'method']}
rules={{ validate: rhfZodValidate(SSMethodSchema) }}
>
<Select options={SS_METHOD_OPTIONS} />
</FormField>
<FormField
label={t('pages.xray.outboundForm.udpOverTcp')}
name={['settings', 'uot']}
valueProp="checked"
>
<Switch />
</FormField>
<FormField label={t('pages.xray.outboundForm.uotVersion')} name={['settings', 'UoTVersion']}>
<InputNumber min={1} max={2} />
</FormField>
</>
);
}
@@ -1,18 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function SocksFields() {
const { t } = useTranslation();
return (
<>
<FormField label={t('username')} name={['settings', 'user']}>
<Input />
</FormField>
<FormField label={t('password')} name={['settings', 'pass']}>
<Input />
</FormField>
</>
);
}
@@ -1,18 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input } from 'antd';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { TrojanOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
export default function TrojanFields() {
const { t } = useTranslation();
return (
<FormField
label={t('password')}
name={['settings', 'password']}
rules={{ validate: rhfZodValidate(TrojanOutboundFormSettingsSchema.shape.password) }}
>
<Input />
</FormField>
);
}
@@ -1,33 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input } from 'antd';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import {
VlessOutboundFormSettingsSchema,
VmessOutboundFormSettingsSchema,
} from '@/schemas/forms/outbound-form';
export default function VlessFields() {
const { t } = useTranslation();
return (
<>
<FormField
label="ID"
name={['settings', 'id']}
rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.id) }}
>
<Input placeholder="UUID" />
</FormField>
<FormField
label={t('encryption')}
name={['settings', 'encryption']}
rules={{ validate: rhfZodValidate(VlessOutboundFormSettingsSchema.shape.encryption) }}
>
<Input />
</FormField>
<FormField label={t('pages.clients.reverseTag')} name={['settings', 'reverseTag']}>
<Input placeholder={t('pages.xray.outboundForm.optional')} />
</FormField>
</>
);
}
@@ -1,29 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input, Select } from 'antd';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { VmessOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
import { SECURITY_OPTIONS } from '../outbound-form-constants';
export default function VmessFields() {
const { t } = useTranslation();
return (
<>
<FormField
label="ID"
name={['settings', 'id']}
rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.id) }}
>
<Input placeholder="UUID" />
</FormField>
<FormField
label={t('security')}
name={['settings', 'security']}
rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.security) }}
>
<Select options={SECURITY_OPTIONS} />
</FormField>
</>
);
}
@@ -1,164 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Wireguard } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
import { WireguardDomainStrategy } from '@/schemas/primitives';
function AllowedIPsList({ peerIndex }: { peerIndex: number }) {
const { t } = useTranslation();
const { control } = useFormContext();
const { fields, append, remove } = useFieldArray({
control,
name: `settings.peers.${peerIndex}.allowedIPs`,
});
return (
<>
{fields.map((field, ipIdx) => (
<Space.Compact key={field.id} block style={{ marginBottom: 4 }}>
<FormField noStyle name={['settings', 'peers', peerIndex, 'allowedIPs', ipIdx]}>
<Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
</FormField>
{fields.length > 1 && (
<InputAddon ariaLabel={t('remove')} onClick={() => remove(ipIdx)}>
<MinusOutlined />
</InputAddon>
)}
</Space.Compact>
))}
<Button
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => append('')}
/>
</>
);
}
export default function WireguardFields() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const {
fields: peerFields,
append: appendPeer,
remove: removePeer,
} = useFieldArray({ control, name: 'settings.peers' });
return (
<>
<FormField label={t('pages.inbounds.address')} name={['settings', 'address']}>
<Input placeholder="comma-separated, e.g. 10.0.0.1,fd00::1" />
</FormField>
<Form.Item label={t('pages.inbounds.privatekey')}>
<Space.Compact block>
<FormField name={['settings', 'secretKey']} noStyle>
<Input
aria-label={t('pages.inbounds.privatekey')}
style={{ width: 'calc(100% - 32px)' }}
/>
</FormField>
<Button
icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => {
const pair = Wireguard.generateKeypair();
setValue('settings.secretKey', pair.privateKey);
setValue('settings.pubKey', pair.publicKey);
}}
/>
</Space.Compact>
</Form.Item>
<FormField label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
<Input disabled />
</FormField>
<FormField
label={t('pages.xray.wireguard.domainStrategy')}
name={['settings', 'domainStrategy']}
>
<Select
options={[
{ value: '', label: `(${t('none')})` },
...WireguardDomainStrategy.map((s) => ({ value: s, label: s })),
]}
/>
</FormField>
<FormField label="MTU" name={['settings', 'mtu']}>
<InputNumber min={0} />
</FormField>
<FormField
label={t('pages.inbounds.info.noKernelTun')}
name={['settings', 'noKernelTun']}
valueProp="checked"
>
<Switch />
</FormField>
<FormField label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
<Input placeholder="comma-separated bytes, e.g. 1,2,3" />
</FormField>
<Form.Item label={t('pages.inbounds.form.peers')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
appendPeer({
publicKey: '',
psk: '',
allowedIPs: ['0.0.0.0/0', '::/0'],
endpoint: '',
keepAlive: 0,
})
}
/>
</Form.Item>
{peerFields.map((field, index) => (
<div key={field.id}>
<Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
<div className="item-heading">
<span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span>
{peerFields.length > 1 && (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removePeer(index)}
onKeyDown={activateOnKey(() => removePeer(index))}
/>
)}
</div>
</Form.Item>
<FormField
label={t('pages.xray.wireguard.endpoint')}
name={['settings', 'peers', index, 'endpoint']}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.publicKey')}
name={['settings', 'peers', index, 'publicKey']}
>
<Input />
</FormField>
<FormField label="PSK" name={['settings', 'peers', index, 'psk']}>
<Input />
</FormField>
<Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
<AllowedIPsList peerIndex={index} />
</Form.Item>
<FormField
label={t('pages.inbounds.info.keepAlive')}
name={['settings', 'peers', index, 'keepAlive']}
>
<InputNumber min={0} />
</FormField>
</div>
))}
</>
);
}
@@ -1,2 +0,0 @@
export { default as TlsForm } from './tls';
export { default as RealityForm } from './reality';
@@ -1,44 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input, Select } from 'antd';
import { FormField } from '@/components/form/rhf';
import { UTLS_OPTIONS } from '../outbound-form-constants';
export default function RealityForm() {
const { t } = useTranslation();
return (
<>
<FormField label="SNI" name={['streamSettings', 'realitySettings', 'serverName']}>
<Input />
</FormField>
<FormField label="uTLS" name={['streamSettings', 'realitySettings', 'fingerprint']}>
<Select options={UTLS_OPTIONS} />
</FormField>
<FormField
label={t('pages.xray.outboundForm.shortId')}
name={['streamSettings', 'realitySettings', 'shortId']}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.form.spiderX')}
name={['streamSettings', 'realitySettings', 'spiderX']}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.publicKey')}
name={['streamSettings', 'realitySettings', 'publicKey']}
>
<Input.TextArea autoSize={{ minRows: 2 }} />
</FormField>
<FormField
label={t('pages.inbounds.form.mldsa65Verify')}
name={['streamSettings', 'realitySettings', 'mldsa65Verify']}
>
<Input.TextArea autoSize={{ minRows: 2 }} />
</FormField>
</>
);
}
@@ -1,42 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input, Select } from 'antd';
import { FormField } from '@/components/form/rhf';
import { ALPN_OPTIONS, UTLS_OPTIONS } from '../outbound-form-constants';
export default function TlsForm() {
const { t } = useTranslation();
return (
<>
<FormField label="SNI" name={['streamSettings', 'tlsSettings', 'serverName']}>
<Input placeholder={t('pages.xray.outboundForm.serverNamePlaceholder')} />
</FormField>
<FormField label="uTLS" name={['streamSettings', 'tlsSettings', 'fingerprint']}>
<Select
allowClear
placeholder={t('none')}
options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS]}
/>
</FormField>
<FormField label="ALPN" name={['streamSettings', 'tlsSettings', 'alpn']}>
<Select mode="multiple" options={ALPN_OPTIONS} />
</FormField>
<FormField label="ECH" name={['streamSettings', 'tlsSettings', 'echConfigList']}>
<Input />
</FormField>
<FormField
label={t('pages.xray.outboundForm.verifyPeerName')}
name={['streamSettings', 'tlsSettings', 'verifyPeerCertByName']}
>
<Input placeholder="cloudflare-dns.com" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.pinnedSha256')}
name={['streamSettings', 'tlsSettings', 'pinnedPeerCertSha256']}
>
<Input placeholder="base64 SHA256" />
</FormField>
</>
);
}
@@ -1,31 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input, Switch } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function GrpcForm() {
const { t } = useTranslation();
return (
<>
<FormField
label={t('pages.inbounds.form.serviceName')}
name={['streamSettings', 'grpcSettings', 'serviceName']}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.form.authority')}
name={['streamSettings', 'grpcSettings', 'authority']}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.form.multiMode')}
name={['streamSettings', 'grpcSettings', 'multiMode']}
valueProp="checked"
>
<Switch />
</FormField>
</>
);
}
@@ -1,25 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function HttpUpgradeForm() {
const { t } = useTranslation();
return (
<>
<FormField label={t('host')} name={['streamSettings', 'httpupgradeSettings', 'host']}>
<Input />
</FormField>
<FormField label={t('path')} name={['streamSettings', 'httpupgradeSettings', 'path']}>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'httpupgradeSettings', 'headers']}
>
<HeaderMapEditor mode="v1" />
</FormField>
</>
);
}
@@ -1,113 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Select, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
const MASQ = ['streamSettings', 'hysteriaSettings', 'masquerade'];
const MASQ_DOT = 'streamSettings.hysteriaSettings.masquerade';
export default function HysteriaForm() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const masquerade = useWatch({ control, name: MASQ_DOT }) as { type?: string } | undefined;
return (
<>
<FormField
label={t('pages.inbounds.form.version')}
name={['streamSettings', 'hysteriaSettings', 'version']}
>
<InputNumber min={2} max={2} disabled style={{ width: '100%' }} />
</FormField>
<FormField
label={t('pages.xray.outboundForm.authPassword')}
name={['streamSettings', 'hysteriaSettings', 'auth']}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.form.udpIdleTimeout')}
name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
>
<InputNumber min={2} max={600} style={{ width: '100%' }} />
</FormField>
<Form.Item label={t('pages.inbounds.form.masquerade')}>
<Switch
checked={!!masquerade}
onChange={(checked) =>
setValue(
MASQ_DOT,
checked
? {
type: '',
dir: '',
url: '',
rewriteHost: false,
insecure: false,
content: '',
headers: {},
statusCode: 0,
}
: undefined,
)
}
/>
</Form.Item>
{masquerade && (
<>
<FormField label={t('pages.inbounds.form.type')} name={[...MASQ, 'type']}>
<Select
options={[
{ value: '', label: 'default (404 page)' },
{ value: 'proxy', label: 'proxy (reverse proxy)' },
{ value: 'file', label: 'file (serve directory)' },
{ value: 'string', label: 'string (fixed body)' },
]}
/>
</FormField>
{masquerade.type === 'proxy' && (
<>
<FormField label={t('pages.inbounds.form.upstreamUrl')} name={[...MASQ, 'url']}>
<Input placeholder="https://www.example.com" />
</FormField>
<FormField
label={t('pages.inbounds.form.rewriteHost')}
name={[...MASQ, 'rewriteHost']}
valueProp="checked"
>
<Switch />
</FormField>
<FormField
label={t('pages.inbounds.form.skipTlsVerify')}
name={[...MASQ, 'insecure']}
valueProp="checked"
>
<Switch />
</FormField>
</>
)}
{masquerade.type === 'file' && (
<FormField label={t('pages.inbounds.form.directory')} name={[...MASQ, 'dir']}>
<Input placeholder="/var/www/html" />
</FormField>
)}
{masquerade.type === 'string' && (
<>
<FormField label={t('pages.inbounds.form.statusCode')} name={[...MASQ, 'statusCode']}>
<InputNumber min={0} max={599} style={{ width: '100%' }} />
</FormField>
<FormField label={t('pages.inbounds.form.body')} name={[...MASQ, 'content']}>
<Input.TextArea autoSize={{ minRows: 3 }} />
</FormField>
<FormField label={t('pages.inbounds.form.headers')} name={[...MASQ, 'headers']}>
<HeaderMapEditor mode="v1" />
</FormField>
</>
)}
</>
)}
</>
);
}
@@ -1,9 +0,0 @@
export { default as RawForm } from './raw';
export { default as KcpForm } from './kcp';
export { default as WsForm } from './ws';
export { default as GrpcForm } from './grpc';
export { default as HttpUpgradeForm } from './httpupgrade';
export { default as XhttpForm } from './xhttp';
export { default as HysteriaForm } from './hysteria';
export { default as SockoptForm } from './sockopt';
export { default as MuxForm } from './mux';
@@ -1,45 +0,0 @@
import { useTranslation } from 'react-i18next';
import { InputNumber } from 'antd';
import { FormField } from '@/components/form/rhf';
export default function KcpForm() {
const { t } = useTranslation();
return (
<>
<FormField label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}>
<InputNumber min={0} />
</FormField>
<FormField
label={t('pages.inbounds.form.ttiMs')}
name={['streamSettings', 'kcpSettings', 'tti']}
>
<InputNumber min={0} />
</FormField>
<FormField
label={t('pages.inbounds.form.uplinkMbps')}
name={['streamSettings', 'kcpSettings', 'uplinkCapacity']}
>
<InputNumber min={0} />
</FormField>
<FormField
label={t('pages.inbounds.form.downlinkMbps')}
name={['streamSettings', 'kcpSettings', 'downlinkCapacity']}
>
<InputNumber min={0} />
</FormField>
<FormField
label={t('pages.inbounds.form.cwndMultiplier')}
name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
>
<InputNumber min={1} />
</FormField>
<FormField
label={t('pages.inbounds.form.maxSendingWindow')}
name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
>
<InputNumber min={0} />
</FormField>
</>
);
}
@@ -1,109 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Form, Input, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function RawForm() {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const type = (useWatch({
control,
name: 'streamSettings.tcpSettings.header.type',
}) ?? 'none') as string;
return (
<>
<Form.Item label={`HTTP ${t('camouflage')}`}>
<Switch
checked={type === 'http'}
onChange={(checked) =>
setValue(
'streamSettings.tcpSettings.header',
checked
? {
type: 'http',
request: {
version: '1.1',
method: 'GET',
path: ['/'],
headers: {},
},
response: {
version: '1.1',
status: '200',
reason: 'OK',
headers: {},
},
}
: { type: 'none' },
)
}
/>
</Form.Item>
{type === 'http' && (
<>
<FormField
label={t('pages.inbounds.form.requestVersion')}
name={['streamSettings', 'tcpSettings', 'header', 'request', 'version']}
>
<Input placeholder="1.1" />
</FormField>
<FormField
label={t('pages.inbounds.form.requestMethod')}
name={['streamSettings', 'tcpSettings', 'header', 'request', 'method']}
>
<Input placeholder="GET" />
</FormField>
<FormField
label={t('pages.inbounds.form.requestPath')}
name={['streamSettings', 'tcpSettings', 'header', 'request', 'path']}
transform={{
input: (v) => (Array.isArray(v) ? v.join(',') : v),
output: (raw) => {
const parts = String(raw ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return parts.length > 0 ? parts : ['/'];
},
}}
>
<Input placeholder="/" />
</FormField>
<FormField
label={t('pages.inbounds.form.requestHeaders')}
name={['streamSettings', 'tcpSettings', 'header', 'request', 'headers']}
>
<HeaderMapEditor mode="v2" />
</FormField>
<FormField
label={t('pages.inbounds.form.responseVersion')}
name={['streamSettings', 'tcpSettings', 'header', 'response', 'version']}
>
<Input placeholder="1.1" />
</FormField>
<FormField
label={t('pages.inbounds.form.responseStatus')}
name={['streamSettings', 'tcpSettings', 'header', 'response', 'status']}
>
<Input placeholder="200" />
</FormField>
<FormField
label={t('pages.inbounds.form.responseReason')}
name={['streamSettings', 'tcpSettings', 'header', 'response', 'reason']}
>
<Input placeholder="OK" />
</FormField>
<FormField
label={t('pages.inbounds.form.responseHeaders')}
name={['streamSettings', 'tcpSettings', 'header', 'response', 'headers']}
>
<HeaderMapEditor mode="v2" />
</FormField>
</>
)}
</>
);
}
@@ -1,31 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Input, InputNumber } from 'antd';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
export default function WsForm() {
const { t } = useTranslation();
return (
<>
<FormField label={t('host')} name={['streamSettings', 'wsSettings', 'host']}>
<Input />
</FormField>
<FormField label={t('path')} name={['streamSettings', 'wsSettings', 'path']}>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.form.heartbeatPeriod')}
name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
>
<InputNumber min={0} />
</FormField>
<FormField
label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'wsSettings', 'headers']}
>
<HeaderMapEditor mode="v1" />
</FormField>
</>
);
}
@@ -1,343 +0,0 @@
import { useTranslation } from 'react-i18next';
import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
import { useFormContext, useWatch } from 'react-hook-form';
import { HeaderMapEditor } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
import { int32RangeUpper } from '@/lib/xray/stream-wire-normalize';
import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
import { MODE_OPTIONS } from '../outbound-form-constants';
interface XhttpFormProps {
onXmuxToggle: (checked: boolean) => void;
}
function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
return async (value: unknown): Promise<true | string> => {
try {
await fn(undefined, value);
return true;
} catch (e) {
return (e as Error).message;
}
};
}
const XH = 'streamSettings.xhttpSettings';
export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
const { t } = useTranslation();
const { control, getValues, setValue } = useFormContext();
const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined;
const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` });
const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as
| string
| undefined;
const table = useWatch({ control, name: `${XH}.sessionIDTable` });
const seqPlacement = useWatch({ control, name: `${XH}.seqPlacement` }) as string | undefined;
const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as
| string
| undefined;
const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
function onXmuxMaxConcurrencyChange(value: unknown) {
if (int32RangeUpper(value) <= 0) return;
if (int32RangeUpper(getValues(`${XH}.xmux.maxConnections`)) > 0) {
setValue(`${XH}.xmux.maxConnections`, 0);
}
}
function onXmuxMaxConnectionsChange(value: unknown) {
if (int32RangeUpper(value) <= 0) return;
if (int32RangeUpper(getValues(`${XH}.xmux.maxConcurrency`)) > 0) {
setValue(`${XH}.xmux.maxConcurrency`, '');
}
}
return (
<>
<FormField label={t('host')} name={['streamSettings', 'xhttpSettings', 'host']}>
<Input />
</FormField>
<FormField label={t('path')} name={['streamSettings', 'xhttpSettings', 'path']}>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.info.mode')}
name={['streamSettings', 'xhttpSettings', 'mode']}
>
<Select options={MODE_OPTIONS} />
</FormField>
<FormField
label={t('pages.inbounds.form.paddingBytes')}
name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.form.headers')}
name={['streamSettings', 'xhttpSettings', 'headers']}
>
<HeaderMapEditor mode="v1" />
</FormField>
{/* Padding obfs sub-section: gated by a Switch.
When on, four extra knobs (key/header/placement/
method) tune how Xray injects random padding to
disguise the post body shape. */}
<FormField
label={t('pages.inbounds.form.paddingObfsMode')}
name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
valueProp="checked"
>
<Switch />
</FormField>
{obfs && (
<>
<FormField
label={t('pages.inbounds.form.paddingKey')}
name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
>
<Input placeholder="x_padding" />
</FormField>
<FormField
label={t('pages.inbounds.form.paddingHeader')}
name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
>
<Input placeholder="X-Padding" />
</FormField>
<FormField
label={t('pages.inbounds.form.paddingPlacement')}
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
>
<Select
options={[
{ value: '', label: 'Default (queryInHeader)' },
{ value: 'queryInHeader', label: 'queryInHeader' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
<FormField
label={t('pages.inbounds.form.paddingMethod')}
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
>
<Select
options={[
{ value: '', label: 'Default (repeat-x)' },
{ value: 'repeat-x', label: 'repeat-x' },
{ value: 'tokenish', label: 'tokenish' },
]}
/>
</FormField>
</>
)}
<FormField
label={t('pages.inbounds.form.uplinkHttpMethod')}
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
>
<Select
placeholder="Default (POST)"
options={[
{ value: '', label: 'Default (POST)' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
]}
/>
</FormField>
{/* Session + sequence + uplinkData placements:
three orthogonal slots Xray uses to thread
request metadata through the transport
(path / header / cookie / query). Key field
only matters when placement is not 'path'. */}
<FormField
label={t('pages.inbounds.form.sessionPlacement')}
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
>
<Select
placeholder="Default (path)"
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
{sessionPlacement && sessionPlacement !== 'path' && (
<FormField
label={t('pages.inbounds.form.sessionKey')}
name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
>
<Input placeholder="x_session" />
</FormField>
)}
<FormField
label={t('pages.inbounds.form.sessionIDTable')}
tooltip={t('pages.inbounds.form.sessionIDTableHint')}
name={['streamSettings', 'xhttpSettings', 'sessionIDTable']}
rules={{ validate: antdValidatorToRhf(validateSessionIDTable) }}
>
<AutoComplete
allowClear
options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))}
placeholder="Base62"
/>
</FormField>
{!!table && (
<FormField
label={t('pages.inbounds.form.sessionIDLength')}
tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
rules={{ validate: antdValidatorToRhf(validateSessionIDLength) }}
>
<Input placeholder="8-16" />
</FormField>
)}
<FormField
label={t('pages.inbounds.form.sequencePlacement')}
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
>
<Select
placeholder="Default (path)"
options={[
{ value: '', label: 'Default (path)' },
{ value: 'path', label: 'path' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
{ value: 'query', label: 'query' },
]}
/>
</FormField>
{seqPlacement && seqPlacement !== 'path' && (
<FormField
label={t('pages.inbounds.form.sequenceKey')}
name={['streamSettings', 'xhttpSettings', 'seqKey']}
>
<Input placeholder="x_seq" />
</FormField>
)}
{/* Mode-conditional sub-sections. */}
{(mode === 'packet-up' || mode === 'auto') && (
<>
<FormField
label={t('pages.xray.outboundForm.minUploadInterval')}
name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
>
<Input placeholder="e.g. 50-150" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.maxUploadSizeBytes')}
name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
>
<Input placeholder="1000000" />
</FormField>
<FormField
label={t('pages.inbounds.form.uplinkDataPlacement')}
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
>
<Select
options={[
{ value: '', label: 'Default (auto)' },
{ value: 'auto', label: 'auto' },
{ value: 'body', label: 'body' },
{ value: 'header', label: 'header' },
{ value: 'cookie', label: 'cookie' },
]}
/>
</FormField>
{uplinkDataPlacement && uplinkDataPlacement !== 'body' && (
<>
<FormField
label={t('pages.inbounds.form.uplinkDataKey')}
name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
>
<Input placeholder="x_data" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.uplinkChunkSize')}
name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
>
<InputNumber min={0} placeholder="0 (unlimited)" style={{ width: '100%' }} />
</FormField>
</>
)}
</>
)}
{(mode === 'stream-up' || mode === 'stream-one') && (
<FormField
label={t('pages.xray.outboundForm.noGrpcHeader')}
name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
valueProp="checked"
>
<Switch />
</FormField>
)}
{/* XMUX is the connection-multiplexing layer
xHTTP uses to fan out parallel requests over
a small pool of upstream connections. UI-only
toggle (enableXmux) hides the 6 nested knobs
when off. */}
<FormField
label="XMUX"
name={['streamSettings', 'xhttpSettings', 'enableXmux']}
valueProp="checked"
onAfterChange={(v) => onXmuxToggle(v as boolean)}
>
<Switch />
</FormField>
{enableXmux && (
<>
<FormField
label={t('pages.xray.outboundForm.maxConcurrency')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
onAfterChange={onXmuxMaxConcurrencyChange}
>
<Input placeholder="16-32" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.maxConnections')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
onAfterChange={onXmuxMaxConnectionsChange}
>
<Input placeholder="0" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.maxReuseTimes')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
>
<Input />
</FormField>
<FormField
label={t('pages.xray.outboundForm.maxRequestTimes')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
>
<Input placeholder="600-900" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.maxReusableSecs')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
>
<Input placeholder="1800-3000" />
</FormField>
<FormField
label={t('pages.xray.outboundForm.keepAlivePeriod')}
name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
</>
)}
</>
);
}
@@ -1,198 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag, Tooltip } from 'antd';
import {
RetweetOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
VerticalAlignTopOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types';
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
interface OutboundColumnsParams {
rows: OutboundRow[];
outboundsTraffic: OutboundTrafficRow[];
openEdit: (idx: number) => void;
setFirst: (idx: number) => void;
moveUp: (idx: number) => void;
moveDown: (idx: number) => void;
confirmDelete: (idx: number) => void;
onResetTraffic: (tag: string) => void;
}
export function useOutboundColumns({
rows,
outboundsTraffic,
openEdit,
setFirst,
moveUp,
moveDown,
confirmDelete,
onResetTraffic,
}: OutboundColumnsParams): ColumnsType<OutboundRow> {
const { t } = useTranslation();
return useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 100,
render: (_v, _record, index) => (
<div className="action-cell">
<span className="row-index">{index + 1}</span>
<div className="action-buttons">
<Button
shape="circle"
size="small"
icon={<EditOutlined />}
aria-label={t('edit')}
onClick={() => openEdit(index)}
/>
<Dropdown
trigger={['click']}
menu={{
items: [
...(index > 0
? [
{
key: 'top',
label: (
<>
<VerticalAlignTopOutlined /> {t('pages.xray.outbound.moveToTop')}
</>
),
onClick: () => setFirst(index),
},
]
: []),
{
key: 'up',
label: (
<>
<ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}
</>
),
disabled: index === 0,
onClick: () => moveUp(index),
},
{
key: 'down',
label: (
<>
<ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}
</>
),
disabled: index === rows.length - 1,
onClick: () => moveDown(index),
},
{
key: 'reset',
label: (
<>
<RetweetOutlined /> {t('pages.inbounds.resetTraffic')}
</>
),
onClick: () => onResetTraffic(rows[index].tag || ''),
},
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => confirmDelete(index),
},
],
}}
>
<Button
shape="circle"
size="small"
icon={<MoreOutlined />}
aria-label={t('more')}
/>
</Dropdown>
</div>
</div>
),
},
{
title: t('pages.xray.outbound.tag'),
key: 'identity',
align: 'left',
render: (_v, record) => (
<div className="identity-cell">
<Tooltip title={record.tag}>
<span className="tag-name">{record.tag}</span>
</Tooltip>
<div className="protocol-line">
<Tag color="green">{record.protocol}</Tag>
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(
record.protocol as never,
) && (
<>
<Tag>{record.streamSettings?.network}</Tag>
{showSecurity(record.streamSettings?.security) && (
<Tag color="purple">{record.streamSettings?.security}</Tag>
)}
</>
)}
</div>
</div>
),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => {
const addrs = outboundAddresses(record);
return (
<div className="address-list">
{addrs.length === 0 ? (
<span className="empty">—</span>
) : (
addrs.map((addr) => (
<Tooltip key={addr} title={addr}>
<span className="address-pill">{addr}</span>
</Tooltip>
))
)}
</div>
);
},
},
{
title: t('pages.inbounds.traffic'),
key: 'traffic',
align: 'left',
width: 200,
render: (_v, record) => {
const tr = trafficFor(outboundsTraffic, record);
return (
<>
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
<span className="traffic-sep" />
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
</>
);
},
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, rows, outboundsTraffic],
);
}
@@ -1,41 +0,0 @@
.nord-data-table {
margin: 5px 0;
width: 100%;
border-collapse: collapse;
}
.nord-data-table td {
padding: 4px 8px;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.nord-data-table td:first-child {
font-family: inherit;
font-weight: 500;
white-space: nowrap;
width: 130px;
}
.nord-data-table .row-odd {
background: var(--ant-color-fill-tertiary);
}
.server-row {
display: inline-flex;
align-items: center;
gap: 8px;
width: 100%;
}
.server-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.server-load-tag {
margin-right: 0;
flex-shrink: 0;
}
@@ -1,448 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil } from '@/utils';
import { FormField } from '@/components/form/rhf';
import './NordModal.css';
interface NordModalProps {
open: boolean;
templateSettings: { outbounds?: { tag?: string }[] } | null;
onClose: () => void;
onAddOutbound: (outbound: Record<string, unknown>) => void;
onResetOutbound: (payload: {
index: number;
outbound: Record<string, unknown>;
oldTag?: string;
newTag: string;
}) => void;
onRemoveOutbound: (index: number) => void;
onRemoveRoutingRules: (payload: { prefix: string }) => void;
}
interface NordData {
token?: string;
private_key?: string;
}
interface Country {
id: number;
name: string;
code: string;
}
interface City {
id: number;
name: string;
}
interface NordServer {
id: number;
name: string;
hostname: string;
station: string;
load: number;
technologies?: { id: number; metadata?: { name: string; value: string }[] }[];
location_ids?: number[];
cityId?: number | null;
cityName?: string;
}
interface NordFormValues {
token: string;
manualKey: string;
countryId: number | null;
cityId: number | null;
serverId: number | null;
}
const EMPTY: NordFormValues = {
token: '',
manualKey: '',
countryId: null,
cityId: null,
serverId: null,
};
function loadColor(load: number): string {
if (load < 30) return 'green';
if (load < 70) return 'orange';
return 'red';
}
export default function NordModal({
open,
templateSettings,
onClose,
onAddOutbound,
onResetOutbound,
onRemoveOutbound,
onRemoveRoutingRules,
}: NordModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [nordData, setNordData] = useState<NordData | null>(null);
const [countries, setCountries] = useState<Country[]>([]);
const [cities, setCities] = useState<City[]>([]);
const [servers, setServers] = useState<NordServer[]>([]);
const methods = useForm<NordFormValues>({ defaultValues: EMPTY });
const cityId = useWatch({ control: methods.control, name: 'cityId' });
const serverId = useWatch({ control: methods.control, name: 'serverId' });
const nordOutboundIndex = useMemo(() => {
const list = templateSettings?.outbounds;
if (!list) return -1;
return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
}, [templateSettings?.outbounds]);
const filteredServers = useMemo(() => {
if (!cityId) return servers;
return servers.filter((s) => s.cityId === cityId);
}, [cityId, servers]);
useEffect(() => {
methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
}, [filteredServers, methods]);
const fetchCountries = useCallback(async () => {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries');
if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj));
}, []);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/data');
if (msg?.success) {
const next = msg.obj ? JSON.parse(msg.obj) : null;
setNordData(next);
if (next) await fetchCountries();
}
} finally {
setLoading(false);
}
}, [fetchCountries]);
useEffect(() => {
if (!open) return;
let cancelled = false;
void (async () => {
await fetchData();
if (cancelled) return;
})();
return () => {
cancelled = true;
};
}, [open, fetchData]);
async function login() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', {
token: methods.getValues('token'),
});
if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj));
await fetchCountries();
}
} finally {
setLoading(false);
}
}
async function saveKey() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', {
key: methods.getValues('manualKey'),
});
if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj));
await fetchCountries();
}
} finally {
setLoading(false);
}
}
async function logout() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/api/xray/nord/del');
if (msg?.success) {
onRemoveOutbound(nordOutboundIndex);
onRemoveRoutingRules({ prefix: 'nord-' });
setNordData(null);
methods.reset(EMPTY);
setCountries([]);
setCities([]);
setServers([]);
}
} finally {
setLoading(false);
}
}
async function fetchServers(newCountryId: number) {
setLoading(true);
setServers([]);
setCities([]);
methods.setValue('serverId', null);
methods.setValue('cityId', null);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', {
countryId: newCountryId,
});
if (!msg?.success || !msg.obj) return;
const data = JSON.parse(msg.obj);
const locations = data.locations || [];
const locToCity: Record<number, City> = {};
const citiesMap = new Map<number, City>();
for (const loc of locations) {
if (loc.country?.city) {
citiesMap.set(loc.country.city.id, loc.country.city);
locToCity[loc.id] = loc.country.city;
}
}
setCities(Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name)));
const next: NordServer[] = (data.servers || [])
.map((s: NordServer) => {
const firstLocId = (s.location_ids || [])[0];
const city = firstLocId != null ? locToCity[firstLocId] : null;
return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
})
.sort((a: NordServer, b: NordServer) => a.load - b.load);
setServers(next);
if (next.length === 0) messageApi.warning(t('pages.xray.nord.noServers'));
} finally {
setLoading(false);
}
}
function buildNordOutbound(): Record<string, unknown> | null {
const selectedServerId = methods.getValues('serverId');
const server = servers.find((s) => s.id === selectedServerId);
if (!server) return null;
const tech = server.technologies?.find((tt) => tt.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
if (!publicKey) {
messageApi.error(t('pages.xray.nord.noPublicKey'));
return null;
}
return {
tag: `nord-${server.hostname}`,
protocol: 'wireguard',
settings: {
secretKey: nordData?.private_key,
address: ['10.5.0.2/32'],
peers: [{ publicKey, endpoint: `${server.station}:51820` }],
// Userspace TUN — same reasoning as the WARP outbound (#5205): kernel
// TUN fails silently on many VPS setups and diverges from the data
// path the panel's connectivity test exercises.
noKernelTun: true,
},
};
}
function addOutbound() {
const ob = buildNordOutbound();
if (!ob) return;
onAddOutbound(ob);
messageApi.success(t('pages.xray.nord.outboundAdded'));
onClose();
}
function resetOutbound() {
if (nordOutboundIndex === -1) return;
const ob = buildNordOutbound();
if (!ob) return;
const oldTag = templateSettings?.outbounds?.[nordOutboundIndex]?.tag;
onResetOutbound({
index: nordOutboundIndex,
outbound: ob,
oldTag,
newTag: ob.tag as string,
});
messageApi.success(t('pages.xray.nord.outboundUpdated'));
onClose();
}
return (
<>
{messageContextHolder}
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
<FormProvider {...methods}>
{nordData == null ? (
<Tabs
defaultActiveKey="token"
items={[
{
key: 'token',
label: t('pages.xray.nord.accessToken'),
children: (
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<FormField name="token" label={t('pages.xray.nord.accessToken')}>
<Input placeholder={t('pages.xray.nord.accessToken')} />
</FormField>
<Button
type="primary"
className="mt-10"
loading={loading}
icon={<LoginOutlined />}
onClick={login}
>
{t('login')}
</Button>
</Form>
),
},
{
key: 'key',
label: t('pages.xray.nord.privateKey'),
children: (
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
<Input placeholder={t('pages.xray.nord.privateKey')} />
</FormField>
<Button
type="primary"
className="mt-10"
loading={loading}
icon={<SaveOutlined />}
onClick={saveKey}
>
{t('save')}
</Button>
</Form>
),
},
]}
/>
) : (
<>
<table className="nord-data-table">
<tbody>
{nordData.token && (
<tr className="row-odd">
<td>{t('pages.xray.nord.accessToken')}</td>
<td>{nordData.token}</td>
</tr>
)}
<tr>
<td>{t('pages.xray.nord.privateKey')}</td>
<td>{nordData.private_key}</td>
</tr>
</tbody>
</table>
<Button loading={loading} type="primary" danger className="mt-8" onClick={logout}>
{t('logout')}
</Button>
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-10"
>
<FormField
name="countryId"
label={t('pages.xray.outbound.country')}
transform={{ input: (v) => v ?? undefined }}
onAfterChange={(v) => fetchServers(v as number)}
>
<Select
showSearch={{ optionFilterProp: 'label' }}
options={countries.map((c) => ({
value: c.id,
label: `${c.name} (${c.code})`,
}))}
/>
</FormField>
{cities.length > 0 && (
<FormField name="cityId" label={t('pages.xray.outbound.city')}>
<Select
showSearch={{ optionFilterProp: 'label' }}
options={[
{ value: null, label: t('pages.xray.outbound.allCities') },
...cities.map((c) => ({ value: c.id, label: c.name })),
]}
/>
</FormField>
)}
{filteredServers.length > 0 && (
<FormField name="serverId" label={t('pages.xray.outbound.server')}>
<Select
showSearch={{ optionFilterProp: 'label' }}
options={filteredServers.map((s) => ({
value: s.id,
label: `${s.cityName} ${s.name} ${s.hostname}`,
children: (
<span className="server-row">
<span className="server-name">
{s.cityName} - {s.name}
</span>
<Tag color={loadColor(s.load)} className="server-load-tag">
{s.load}%
</Tag>
</span>
),
}))}
/>
</FormField>
)}
</Form>
<Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
{nordOutboundIndex >= 0 ? (
<>
<Tag color="green">{t('enabled')}</Tag>
<Button
type="primary"
danger
loading={loading}
className="ml-8"
onClick={resetOutbound}
>
{t('reset')}
</Button>
</>
) : (
<>
<Tag color="orange">{t('disabled')}</Tag>
<Button
type="primary"
className="ml-8"
disabled={!serverId}
loading={loading}
onClick={addOutbound}
>
{t('pages.xray.warp.addOutbound')}
</Button>
</>
)}
</>
)}
</FormProvider>
</Modal>
</>
);
}
@@ -1,53 +0,0 @@
.pia-data-table {
margin: 5px 0;
width: 100%;
border-collapse: collapse;
}
.pia-data-table td {
padding: 4px 8px;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.pia-data-table td:first-child {
font-family: inherit;
font-weight: 500;
white-space: nowrap;
width: 130px;
}
.pia-data-table .row-odd {
background: var(--ant-color-fill-tertiary);
}
.pia-already-added {
margin-top: 8px;
color: var(--ant-color-text-secondary);
font-size: 12px;
}
.pia-added-table {
margin: 0;
width: 100%;
border-collapse: collapse;
}
.pia-added-table td {
padding: 6px 0;
vertical-align: middle;
}
.pia-added-table td:first-child {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
word-break: break-all;
padding-right: 8px;
}
.pia-added-table td:last-child {
width: 1%;
white-space: nowrap;
text-align: right;
}
@@ -1,468 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, message, Modal, Select } from 'antd';
import { LoginOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil } from '@/utils';
import { FormField } from '@/components/form/rhf';
import { countryFlag, countryName } from '../outbounds/outbounds-tab-helpers';
import './PiaModal.css';
interface PiaOutboundRow {
tag?: string;
piaHostname?: string;
}
interface PiaModalProps {
open: boolean;
templateSettings: { outbounds?: PiaOutboundRow[] } | null;
onClose: () => void;
onAddOutbound: (outbound: Record<string, unknown>) => void;
onResetOutbound: (payload: {
index: number;
outbound: Record<string, unknown>;
oldTag?: string;
newTag: string;
}) => void;
}
interface PiaAccount {
username?: string;
accountHint?: string;
}
interface PiaCountry {
code: string;
}
interface PiaRegion {
id: string;
name: string;
}
interface PiaServer {
hostname: string;
ip: string;
regionId: string;
regionName: string;
}
interface PiaKey {
tag: string;
hostname: string;
secretKey: string;
address: string;
publicKey: string;
endpoint: string;
}
interface PiaFormValues {
username: string;
password: string;
countryCode: string | null;
regionId: string | null;
hostname: string | null;
}
const EMPTY: PiaFormValues = {
username: '',
password: '',
countryCode: null,
regionId: null,
hostname: null,
};
function piaHostnameOf(outbound: PiaOutboundRow): string {
if (typeof outbound.piaHostname === 'string' && outbound.piaHostname.trim()) {
return outbound.piaHostname.trim();
}
return '';
}
function piaTagPart(s: string, stripDomain: boolean): string {
s = s.trim().toLowerCase();
if (stripDomain) {
const i = s.indexOf('.');
if (i > 0) s = s.slice(0, i);
}
return s.replaceAll('_', '-');
}
function piaOutboundTag(regionId: string, hostname: string): string {
const region = piaTagPart(regionId, false);
const server = piaTagPart(hostname, true);
if (!region) return `pia-${server}`;
return `pia-${region}-${server}`;
}
function buildPiaOutbound(key: PiaKey): Record<string, unknown> {
return {
tag: key.tag || `pia-${key.hostname}`,
piaHostname: key.hostname,
protocol: 'wireguard',
settings: {
secretKey: key.secretKey,
address: [key.address],
mtu: 1420,
noKernelTun: true,
peers: [
{
publicKey: key.publicKey,
endpoint: key.endpoint,
allowedIPs: ['0.0.0.0/0'],
keepAlive: 25,
},
],
},
};
}
export default function PiaModal({
open,
templateSettings,
onClose,
onAddOutbound,
onResetOutbound,
}: PiaModalProps) {
const { t, i18n } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [piaData, setPiaData] = useState<PiaAccount | null>(null);
const [countries, setCountries] = useState<PiaCountry[]>([]);
const [regions, setRegions] = useState<PiaRegion[]>([]);
const [servers, setServers] = useState<PiaServer[]>([]);
const methods = useForm<PiaFormValues>({ defaultValues: EMPTY });
const regionId = useWatch({ control: methods.control, name: 'regionId' });
const hostname = useWatch({ control: methods.control, name: 'hostname' });
const locale = i18n.resolvedLanguage || i18n.language;
const piaRows = useMemo(() => {
const list = templateSettings?.outbounds;
if (!list) return [];
return list.flatMap((outbound, index) => {
if (!outbound?.tag?.startsWith?.('pia-')) return [];
return [{ index, tag: outbound.tag, hostname: piaHostnameOf(outbound) }];
});
}, [templateSettings?.outbounds]);
const addedHostnames = useMemo(
() => new Set(piaRows.map((row) => row.hostname).filter(Boolean)),
[piaRows],
);
const addedTags = useMemo(
() => new Set(piaRows.map((row) => row.tag).filter(Boolean)),
[piaRows],
);
const filteredServers = useMemo(() => {
if (!regionId) return servers;
return servers.filter((s) => s.regionId === regionId);
}, [regionId, servers]);
const selectedServer = filteredServers.find((s) => s.hostname === hostname);
const selectedTag = selectedServer
? piaOutboundTag(selectedServer.regionId, selectedServer.hostname)
: '';
const selectedAlreadyAdded = Boolean(
(hostname && addedHostnames.has(hostname)) || (selectedTag && addedTags.has(selectedTag)),
);
useEffect(() => {
methods.setValue('hostname', filteredServers.length > 0 ? filteredServers[0].hostname : null);
}, [filteredServers, methods]);
const fetchCountries = useCallback(async () => {
const msg = await HttpUtil.post<PiaCountry[]>('/panel/api/xray/pia/countries');
if (msg?.success && Array.isArray(msg.obj)) setCountries(msg.obj);
}, []);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.post<PiaAccount | null>('/panel/api/xray/pia/data');
if (msg?.success) {
const next = msg.obj ?? null;
setPiaData(next);
if (next) await fetchCountries();
}
} finally {
setLoading(false);
}
}, [fetchCountries]);
useEffect(() => {
if (!open) return;
let cancelled = false;
void (async () => {
await fetchData();
if (cancelled) return;
})();
return () => {
cancelled = true;
};
}, [open, fetchData]);
async function login() {
setLoading(true);
try {
const msg = await HttpUtil.post<PiaAccount>('/panel/api/xray/pia/reg', {
username: methods.getValues('username'),
password: methods.getValues('password'),
});
if (msg?.success && msg.obj) {
setPiaData(msg.obj);
methods.setValue('password', '');
await fetchCountries();
}
} finally {
setLoading(false);
}
}
async function logout() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/api/xray/pia/del');
if (msg?.success) {
setPiaData(null);
methods.reset(EMPTY);
setCountries([]);
setRegions([]);
setServers([]);
}
} finally {
setLoading(false);
}
}
async function fetchServers(newCountryCode: string) {
setLoading(true);
setServers([]);
setRegions([]);
methods.setValue('hostname', null);
methods.setValue('regionId', null);
try {
const msg = await HttpUtil.post<{ regions?: PiaRegion[]; servers?: PiaServer[] }>(
'/panel/api/xray/pia/servers',
{ countryCode: newCountryCode },
);
if (!msg?.success || !msg.obj) return;
const nextRegions = msg.obj.regions || [];
const nextServers = msg.obj.servers || [];
setRegions(nextRegions);
setServers(nextServers);
if (nextServers.length === 0) messageApi.warning(t('pages.xray.pia.noServers'));
} finally {
setLoading(false);
}
}
async function provisionOutbound(
selectedHostname: string,
): Promise<Record<string, unknown> | null> {
if (!selectedHostname) return null;
const msg = await HttpUtil.post<PiaKey>('/panel/api/xray/pia/addKey', {
hostname: selectedHostname,
});
if (!msg?.success) return null;
if (!msg.obj?.secretKey || !msg.obj.publicKey || !msg.obj.endpoint || !msg.obj.address) {
messageApi.error(t('pages.xray.pia.provisionFailed'));
return null;
}
return buildPiaOutbound(msg.obj);
}
async function addOutbound() {
const selected = methods.getValues('hostname');
if (!selected || selectedAlreadyAdded) return;
setLoading(true);
try {
const ob = await provisionOutbound(selected);
if (!ob) return;
const tag = typeof ob.tag === 'string' ? ob.tag : '';
if (tag && templateSettings?.outbounds?.some((outbound) => outbound?.tag === tag)) return;
onAddOutbound(ob);
messageApi.success(t('pages.xray.pia.outboundAdded'));
} finally {
setLoading(false);
}
}
async function resetOutbound(index: number, selectedHostname: string) {
if (!selectedHostname) return;
setLoading(true);
try {
const ob = await provisionOutbound(selectedHostname);
if (!ob) return;
const oldTag = templateSettings?.outbounds?.[index]?.tag;
onResetOutbound({
index,
outbound: ob,
oldTag,
newTag: ob.tag as string,
});
messageApi.success(t('pages.xray.pia.outboundUpdated'));
} finally {
setLoading(false);
}
}
return (
<>
{messageContextHolder}
<Modal open={open} title="Private Internet Access WireGuard" footer={null} onCancel={onClose}>
<FormProvider {...methods}>
{piaData == null ? (
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-20"
>
<FormField name="username" label={t('pages.xray.pia.username')}>
<Input placeholder={t('pages.xray.pia.username')} autoComplete="username" />
</FormField>
<FormField name="password" label={t('pages.xray.pia.password')}>
<Input.Password
placeholder={t('pages.xray.pia.password')}
autoComplete="current-password"
/>
</FormField>
<Button
type="primary"
className="mt-10"
loading={loading}
icon={<LoginOutlined />}
onClick={() => void login()}
>
{t('login')}
</Button>
</Form>
) : (
<>
<table className="pia-data-table">
<tbody>
<tr className="row-odd">
<td>{t('pages.xray.pia.account')}</td>
<td>{piaData.accountHint || piaData.username}</td>
</tr>
</tbody>
</table>
<Button
loading={loading}
type="primary"
danger
className="mt-8"
onClick={() => void logout()}
>
{t('logout')}
</Button>
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 18 } }}
className="mt-10"
>
<FormField
name="countryCode"
label={t('pages.xray.outbound.country')}
transform={{ input: (v) => v ?? undefined }}
onAfterChange={(v) => void fetchServers(v as string)}
>
<Select
data-testid="pia-country-select"
showSearch={{ optionFilterProp: 'label' }}
options={countries.map((c) => {
const name = countryName(c.code, locale) || c.code;
const flag = countryFlag(c.code);
return {
value: c.code,
label: `${flag ? `${flag} ` : ''}${name} (${c.code})`,
};
})}
/>
</FormField>
{regions.length > 0 && (
<FormField name="regionId" label={t('pages.xray.pia.region')}>
<Select
data-testid="pia-region-select"
showSearch={{ optionFilterProp: 'label' }}
options={[
{ value: null, label: t('pages.xray.pia.allRegions') },
...regions.map((r) => ({ value: r.id, label: r.name })),
]}
/>
</FormField>
)}
{filteredServers.length > 0 && (
<FormField name="hostname" label={t('pages.xray.outbound.server')}>
<Select
data-testid="pia-server-select"
showSearch={{ optionFilterProp: 'label' }}
options={filteredServers.map((s) => ({
value: s.hostname,
label: `${s.regionName} ${s.hostname} ${s.ip}`,
}))}
/>
</FormField>
)}
</Form>
<Button
type="primary"
className="mt-10"
disabled={!hostname || selectedAlreadyAdded}
loading={loading}
onClick={() => void addOutbound()}
>
{t('pages.xray.warp.addOutbound')}
</Button>
{selectedAlreadyAdded && (
<div className="pia-already-added">
{t('pages.xray.pia.alreadyAdded', { reset: t('reset') })}
</div>
)}
{piaRows.length > 0 && (
<>
<Divider className="my-10">{t('pages.xray.pia.addedServers')}</Divider>
<table className="pia-added-table" data-testid="pia-added-table">
<tbody>
{piaRows.map((row) => (
<tr key={`${row.index}-${row.tag}`}>
<td>{row.tag}</td>
<td>
<Button
type="primary"
danger
size="small"
loading={loading}
disabled={!row.tag}
data-testid={`pia-reset-${row.index}`}
onClick={() =>
void resetOutbound(row.index, row.hostname || row.tag || '')
}
>
{t('reset')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</>
)}
</FormProvider>
</Modal>
</>
);
}
@@ -1,35 +0,0 @@
.warp-data-table {
margin: 5px 0;
width: 100%;
border-collapse: collapse;
}
.warp-data-table td {
padding: 4px 8px;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.warp-data-table td:first-child {
font-family: inherit;
font-weight: 500;
white-space: nowrap;
width: 130px;
}
.warp-data-table .row-odd {
background: var(--ant-color-fill-tertiary);
}
.license-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.license-error {
flex: 1;
min-width: 0;
}
@@ -1,546 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Divider, Form, Input, message, Modal, Tag } from 'antd';
import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
import { FormField } from '@/components/form/rhf';
import './WarpModal.css';
interface WarpModalProps {
open: boolean;
templateSettings: { outbounds?: { tag?: string }[] } | null;
onClose: () => void;
onAddOutbound: (outbound: Record<string, unknown>) => void;
onResetOutbound: (payload: { index: number; outbound: Record<string, unknown> }) => void;
onRemoveOutbound: (tag: string) => void;
}
interface WarpData {
access_token?: string;
device_id?: string;
license_key?: string;
private_key?: string;
client_id?: string;
}
interface WarpConfig {
name?: string;
model?: string;
enabled?: boolean;
config?: {
client_id?: string;
interface?: { addresses?: { v4?: string; v6?: string } };
peers?: { public_key?: string; endpoint?: { host?: string } }[];
};
account?: {
account_type?: string;
role?: string;
premium_data?: number;
quota?: number;
usage?: number;
};
}
interface WarpFormValues {
warpPlus: string;
updateInterval: number;
}
const EMPTY: WarpFormValues = { warpPlus: '', updateInterval: 0 };
function addressesFor(addrs: { v4?: string; v6?: string }): string[] {
const out: string[] = [];
if (addrs.v4) out.push(`${addrs.v4}/32`);
if (addrs.v6) out.push(`${addrs.v6}/128`);
return out;
}
function reservedFor(clientId?: string): number[] {
if (!clientId) return [];
const decoded = atob(clientId);
const out: number[] = [];
for (let i = 0; i < decoded.length; i += 1) out.push(decoded.charCodeAt(i));
return out;
}
export function mergeWarpRotation(
existing: Record<string, unknown> | undefined,
data: WarpData | null,
config: WarpConfig | null,
): Record<string, unknown> | null {
const cfg = config?.config;
const peer = cfg?.peers?.[0];
if (!cfg || !peer) return null;
const base: Record<string, unknown> =
existing && typeof existing === 'object'
? { ...existing }
: { tag: 'warp', protocol: 'wireguard' };
const prevSettings =
base.settings && typeof base.settings === 'object'
? { ...(base.settings as Record<string, unknown>) }
: {};
const prevPeers = Array.isArray(prevSettings.peers)
? [...(prevSettings.peers as Record<string, unknown>[])]
: [];
const prevFirstPeer =
prevPeers[0] && typeof prevPeers[0] === 'object'
? { ...(prevPeers[0] as Record<string, unknown>) }
: {};
prevFirstPeer.publicKey = peer.public_key;
prevFirstPeer.endpoint = peer.endpoint?.host;
prevPeers[0] = prevFirstPeer;
prevSettings.secretKey = data?.private_key;
prevSettings.address = addressesFor(cfg.interface?.addresses || {});
prevSettings.reserved = reservedFor(cfg.client_id ?? data?.client_id);
prevSettings.peers = prevPeers;
base.settings = prevSettings;
base.tag = 'warp';
base.protocol = 'wireguard';
return base;
}
export default function WarpModal({
open,
templateSettings,
onClose,
onAddOutbound,
onResetOutbound,
onRemoveOutbound,
}: WarpModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [warpData, setWarpData] = useState<WarpData | null>(null);
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
const [licenseError, setLicenseError] = useState('');
const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
const methods = useForm<WarpFormValues>({ defaultValues: EMPTY });
const warpPlusValue = useWatch({ control: methods.control, name: 'warpPlus' }) ?? '';
const warpOutboundIndex = useMemo(() => {
const list = templateSettings?.outbounds;
if (!list) return -1;
return list.findIndex((o) => o?.tag === 'warp');
}, [templateSettings?.outbounds]);
const collectConfig = useCallback(
(data: WarpData | null, config: WarpConfig | null): Record<string, unknown> | null => {
const cfg = config?.config;
if (!cfg?.peers?.length) return null;
const peer = cfg.peers[0];
const outbound: Record<string, unknown> = {
tag: 'warp',
protocol: 'wireguard',
settings: {
mtu: 1420,
secretKey: data?.private_key,
address: addressesFor(cfg.interface?.addresses || {}),
reserved: reservedFor(cfg.client_id ?? data?.client_id),
// Prefer IPv4 with IPv6 fallback: plain ForceIP may pick the AAAA
// record for engage.cloudflareclient.com, and a host with
// half-configured IPv6 then blackholes the handshake with no error
// logged (#5205).
domainStrategy: 'ForceIPv4v6',
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
// Userspace TUN: kernel TUN needs CAP_NET_ADMIN + fwmark routing and
// fails silently on many VPS setups, and it is a different data path
// than the panel's connectivity test (which always probes with
// noKernelTun=true), so "test ok" and "traffic flows" can disagree.
noKernelTun: true,
},
};
setStagedOutbound(outbound);
return outbound;
},
[],
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/data');
if (msg?.success) {
const raw = msg.obj;
setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
}
const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all');
if (settingMsg?.success && settingMsg.obj) {
methods.setValue('updateInterval', Number(settingMsg.obj.warpUpdateInterval) || 0);
}
} finally {
setLoading(false);
}
}, [methods]);
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setWarpConfig(null);
setStagedOutbound(null);
setLicenseError('');
}
}
useEffect(() => {
if (!open) return;
let cancelled = false;
void (async () => {
await fetchData();
if (cancelled) return;
})();
return () => {
cancelled = true;
};
}, [open, fetchData]);
async function register() {
setLoading(true);
try {
const keys = Wireguard.generateKeypair();
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/reg', keys);
if (msg?.success && msg.obj) {
const resp = JSON.parse(msg.obj);
setWarpData(resp.data);
setWarpConfig(resp.config);
collectConfig(resp.data, resp.config);
}
} finally {
setLoading(false);
}
}
async function getConfig() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/config');
if (msg?.success && msg.obj) {
const parsed = JSON.parse(msg.obj);
setWarpConfig(parsed);
collectConfig(warpData, parsed);
}
} finally {
setLoading(false);
}
}
async function changeIp() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/changeIp');
if (msg?.success && msg.obj) {
const parsed = JSON.parse(msg.obj);
setWarpData(parsed.data);
setWarpConfig(parsed.config);
collectConfig(parsed.data, parsed.config);
if (warpOutboundIndex >= 0) {
const existing = templateSettings?.outbounds?.[warpOutboundIndex] as
| Record<string, unknown>
| undefined;
const merged = mergeWarpRotation(existing, parsed.data, parsed.config);
if (merged) {
onResetOutbound({ index: warpOutboundIndex, outbound: merged });
}
}
if (parsed.warning) {
messageApi.warning(parsed.warning);
}
messageApi.success(t('pages.xray.warp.changeIpSuccess', 'WARP IP changed successfully!'));
}
} finally {
setLoading(false);
}
}
async function saveInterval() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/api/xray/warp/interval', {
interval: methods.getValues('updateInterval'),
});
if (msg?.success) {
messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
}
} finally {
setLoading(false);
}
}
async function updateLicense() {
const licenseValue = methods.getValues('warpPlus');
if (licenseValue.length < 26) return;
setLoading(true);
setLicenseError('');
try {
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', {
license: licenseValue,
});
if (msg?.success && msg.obj) {
setWarpData(JSON.parse(msg.obj));
setWarpConfig(null);
methods.setValue('warpPlus', '');
} else {
setLicenseError(msg?.msg || t('pages.xray.warp.licenseError'));
}
} finally {
setLoading(false);
}
}
async function delConfig() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/api/xray/warp/del');
if (msg?.success) {
setWarpData(null);
setWarpConfig(null);
setStagedOutbound(null);
onRemoveOutbound('warp');
onClose();
}
} finally {
setLoading(false);
}
}
function addOutbound() {
if (!stagedOutbound) {
messageApi.warning(t('pages.xray.warp.fetchFirst'));
return;
}
onAddOutbound(stagedOutbound);
onClose();
}
function resetOutbound() {
if (!stagedOutbound) return;
onResetOutbound({ index: warpOutboundIndex, outbound: stagedOutbound });
onClose();
}
const hasWarp = !ObjectUtil.isEmpty(warpData);
const hasConfig = !ObjectUtil.isEmpty(warpConfig);
return (
<>
{messageContextHolder}
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
<FormProvider {...methods}>
{!hasWarp ? (
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
{t('pages.xray.warp.createAccount')}
</Button>
) : (
<>
<table className="warp-data-table">
<tbody>
<tr className="row-odd">
<td>{t('pages.xray.warp.accessToken')}</td>
<td>{warpData?.access_token}</td>
</tr>
<tr>
<td>{t('pages.xray.warp.deviceId')}</td>
<td>{warpData?.device_id}</td>
</tr>
<tr className="row-odd">
<td>{t('pages.xray.warp.licenseKey')}</td>
<td>{warpData?.license_key}</td>
</tr>
<tr>
<td>{t('pages.xray.warp.privateKey')}</td>
<td>{warpData?.private_key}</td>
</tr>
</tbody>
</table>
<Button
loading={loading}
type="primary"
danger
className="mt-8"
icon={<DeleteOutlined />}
onClick={delConfig}
>
{t('pages.xray.warp.deleteAccount')}
</Button>
<Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
<Collapse
className="my-10"
items={[
{
key: '1',
label: t('pages.xray.warp.licenseKeyLabel'),
children: (
<Form
colon={false}
labelCol={{ md: { span: 6 } }}
wrapperCol={{ md: { span: 14 } }}
>
<FormField
name="warpPlus"
label={t('pages.xray.warp.key')}
onAfterChange={() => setLicenseError('')}
>
<Input placeholder={t('pages.xray.warp.keyPlaceholder')} />
</FormField>
<div className="license-actions mt-8">
<Button
type="primary"
disabled={warpPlusValue.length < 26}
loading={loading}
onClick={updateLicense}
>
{t('update')}
</Button>
{licenseError && (
<Alert
title={licenseError}
type="error"
showIcon
className="license-error"
/>
)}
</div>
</Form>
),
},
{
key: '2',
label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
children: (
<Form
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 12 } }}
>
<FormField
name="updateInterval"
label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
tooltip={t(
'pages.xray.warp.intervalDesc',
'0 to disable. Changes IP address automatically.',
)}
transform={{ output: (v) => Number(v) }}
>
<Input type="number" min={0} />
</FormField>
<Button
className="mt-8"
type="primary"
loading={loading}
onClick={saveInterval}
>
{t('save', 'Save')}
</Button>
</Form>
),
},
]}
/>
<Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
<div className="my-8">
<Button
loading={loading}
type="primary"
icon={<SyncOutlined />}
onClick={getConfig}
>
{t('refresh')}
</Button>
<Button
loading={loading}
type="primary"
className="ml-8"
icon={<SyncOutlined />}
onClick={changeIp}
>
{t('pages.xray.warp.changeIp', 'Change IP')}
</Button>
</div>
{hasConfig && (
<>
<table className="warp-data-table">
<tbody>
<tr className="row-odd">
<td>{t('pages.xray.warp.deviceName')}</td>
<td>{warpConfig?.name}</td>
</tr>
<tr>
<td>{t('pages.xray.warp.deviceModel')}</td>
<td>{warpConfig?.model}</td>
</tr>
<tr className="row-odd">
<td>{t('pages.xray.warp.deviceEnabled')}</td>
<td>{String(warpConfig?.enabled)}</td>
</tr>
{warpConfig?.account && (
<>
<tr>
<td>{t('pages.xray.warp.accountType')}</td>
<td>{warpConfig.account.account_type}</td>
</tr>
<tr className="row-odd">
<td>{t('pages.xray.warp.role')}</td>
<td>{warpConfig.account.role}</td>
</tr>
<tr>
<td>{t('pages.xray.warp.warpPlusData')}</td>
<td>{SizeFormatter.sizeFormat(warpConfig.account.premium_data)}</td>
</tr>
<tr className="row-odd">
<td>{t('pages.xray.warp.quota')}</td>
<td>{SizeFormatter.sizeFormat(warpConfig.account.quota)}</td>
</tr>
{warpConfig.account.usage != null && (
<tr>
<td>{t('pages.xray.warp.usage')}</td>
<td>{SizeFormatter.sizeFormat(warpConfig.account.usage)}</td>
</tr>
)}
</>
)}
</tbody>
</table>
<Divider className="my-10">{t('pages.xray.outbound.outboundStatus')}</Divider>
{warpOutboundIndex >= 0 ? (
<>
<Tag color="green">{t('enabled')}</Tag>
<Button
type="primary"
danger
loading={loading}
className="ml-8"
onClick={resetOutbound}
>
{t('reset')}
</Button>
</>
) : (
<>
<Tag color="orange">{t('disabled')}</Tag>
<Button
type="primary"
loading={loading}
className="ml-8"
icon={<PlusOutlined />}
onClick={addOutbound}
>
{t('pages.xray.warp.addOutbound')}
</Button>
</>
)}
</>
)}
</>
)}
</FormProvider>
</Modal>
</>
);
}
@@ -1,3 +0,0 @@
export { default as WarpModal } from './WarpModal';
export { default as NordModal } from './NordModal';
export { default as PiaModal } from './PiaModal';
@@ -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>): 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<string>,
removedBalancers: Set<string>,
): { 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<string>,
removedBalancers: Set<string>,
): 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<string>,
removedBalancers: Set<string>,
): 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<string>,
removedBalancers: Set<string>,
): { 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<string>();
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<string>(), 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);
}
@@ -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 (
<Tooltip title={title}>
<span className="criterion-row">
<span className="criterion-label">{label}</span>
<span className="criterion-value">{parts[0]}</span>
{parts.length > 1 && <span className="criterion-more">+{parts.length - 1}</span>}
</span>
</Tooltip>
);
}
@@ -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<string>(['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 (
<>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.blockConnectionsConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.defaultOutbound')}
description={t('pages.xray.defaultOutboundDesc')}
paddings="small"
control={
<Select
value={defaultOutboundTag}
style={{ width: '100%' }}
options={defaultOutboundOptions}
onChange={(tag) => mutate((tt) => setDefaultOutboundTag(tt, tag))}
/>
}
/>
<SettingListItem
title={t('pages.xray.Torrent')}
paddings="small"
control={
<Switch
checked={torrentActive}
onChange={(checked) =>
mutate((tt) => {
const next = checked
? [...blockedProtocols, ...BITTORRENT_PROTOCOLS]
: blockedProtocols.filter((d) => !BITTORRENT_PROTOCOLS.includes(d));
ruleSetter(tt, 'blocked', 'protocol', next);
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.blockips')}
paddings="small"
control={
<Select
mode="tags"
value={blockedIPs}
style={{ width: '100%' }}
options={IPS_OPTIONS}
onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'ip', v))}
/>
}
/>
<SettingListItem
title={t('pages.xray.blockdomains')}
paddings="small"
control={
<Select
mode="tags"
value={blockedDomains}
style={{ width: '100%' }}
options={BLOCK_DOMAINS_OPTIONS}
onChange={(v) => mutate((tt) => ruleSetter(tt, 'blocked', 'domain', v))}
/>
}
/>
<Alert
type="warning"
showIcon
className="mb-12 hint-alert"
title={t('pages.xray.directConnectionsConfigsDesc')}
/>
<SettingListItem
title={t('pages.xray.directips')}
paddings="small"
control={
<Select
mode="tags"
value={directIPs}
style={{ width: '100%' }}
options={IPS_OPTIONS}
onChange={(v) =>
mutate((tt) => {
ruleSetter(tt, 'direct', 'ip', v);
syncOutbound(tt, 'direct', directSettings);
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.directdomains')}
paddings="small"
control={
<Select
mode="tags"
value={directDomains}
style={{ width: '100%' }}
options={DOMAINS_OPTIONS}
onChange={(v) =>
mutate((tt) => {
ruleSetter(tt, 'direct', 'domain', v);
syncOutbound(tt, 'direct', directSettings);
})
}
/>
}
/>
<SettingListItem
title={t('pages.xray.ipv4Routing')}
description={t('pages.xray.ipv4RoutingDesc')}
paddings="small"
control={
<Select
mode="tags"
value={ipv4Domains}
style={{ width: '100%' }}
options={SERVICES_OPTIONS}
onChange={(v) =>
mutate((tt) => {
ruleSetter(tt, 'IPv4', 'domain', v);
syncOutbound(tt, 'IPv4', ipv4Settings);
})
}
/>
}
/>
</>
);
}
@@ -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);
}
@@ -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<RoutingRule | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(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<RuleRow[]>([]);
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<string>();
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<string>(['']);
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<string, unknown>) {
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}
<Tabs
defaultActiveKey="basic"
items={[
{
key: 'basic',
label: catTabLabel(<ControlOutlined />, t('pages.xray.basicRouting'), isMobile),
children: (
<RoutingBasic
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
/>
),
},
{
key: 'rules',
label: catTabLabel(<UnorderedListOutlined />, t('pages.xray.Routings'), isMobile),
children: (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Routings')}
</Button>
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: 'import',
icon: <ImportOutlined />,
label: t('pages.xray.importRules'),
onClick: () => setImportOpen(true),
},
{
key: 'export',
icon: <ExportOutlined />,
label: t('pages.xray.exportRules'),
disabled: rules.length === 0,
onClick: exportRules,
},
],
}}
>
<Button icon={<MoreOutlined />}>{t('more')}</Button>
</Dropdown>
</Space>
{isMobile ? (
<RuleCardList
rows={rows}
draggedIndex={draggedIndex}
dropTargetIndex={dropTargetIndex}
onHandlePointerDown={onHandlePointerDown}
openEdit={openEdit}
moveUp={moveUp}
moveDown={moveDown}
confirmDelete={confirmDelete}
toggleRule={toggleRule}
/>
) : (
<Table
columns={desktopColumns}
dataSource={rows}
rowKey={(r) => 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<HTMLElement>;
}}
/>
)}
</Space>
),
},
]}
/>
<RuleFormModal
open={ruleModalOpen}
rule={editingRule}
inboundTags={inboundTagOptions}
outboundTags={outboundTagOptions}
balancerTags={balancerTagOptions}
onClose={() => setRuleModalOpen(false)}
onConfirm={onRuleConfirm}
/>
<PromptModal
open={importOpen}
onClose={() => setImportOpen(false)}
title={t('pages.xray.importRules')}
okText={t('pages.xray.importRules')}
type="textarea"
json
onConfirm={importRules}
/>
<TextModal
open={exportOpen}
onClose={() => setExportOpen(false)}
title={t('pages.xray.exportRules')}
content={exportContent}
fileName="routing-rules.json"
json
/>
</>
);
}
@@ -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 (
<div className="rule-list">
{rows.length === 0 ? (
<div className="rule-empty">—</div>
) : (
rows.map((rule, index) => (
<div
key={rule.key}
className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
dropTargetIndex === index && draggedIndex != null && index < draggedIndex
? 'drop-before'
: ''
} ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''} ${
rule.enabled === false ? 'rule-disabled' : ''
}`}
data-row-key={index}
>
<div className="rule-card-head">
<HolderOutlined
className="drag-handle"
aria-hidden="true"
onPointerDown={(ev) => onHandlePointerDown(index, ev)}
/>
<span className="rule-number">#{index + 1}</span>
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEdit(index),
},
{
key: 'up',
label: (
<>
<ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}
</>
),
disabled: index === 0,
onClick: () => moveUp(index),
},
{
key: 'down',
label: (
<>
<ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}
</>
),
disabled: index === rows.length - 1,
onClick: () => moveDown(index),
},
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => confirmDelete(index),
},
],
}}
>
<Button
shape="circle"
size="small"
icon={<MoreOutlined />}
aria-label={t('more')}
/>
</Dropdown>
<Switch
size="small"
checked={rule.enabled !== false}
onChange={(checked) => toggleRule(index, checked)}
disabled={isApiRule(rule)}
style={{ marginLeft: 8 }}
/>
</div>
<div className="rule-flow">
<div className="flow-side">
<span className="flow-label">{t('pages.xray.Inbounds')}</span>
{rule.inboundTag ? (
<Tooltip title={inboundTagsDisplayTitle(rule.inboundTag, remarkByTag)}>
<Tag color="blue" className="flow-tag">
{inboundTagChipPreview(rule.inboundTag, remarkByTag)}
</Tag>
</Tooltip>
) : (
<span className="criterion-empty">any</span>
)}
</div>
<span className="flow-arrow">→</span>
<div className="flow-side flow-side-target">
<span className="flow-label">
{rule.balancerTag
? t('pages.xray.balancer') || 'Balancer'
: t('pages.xray.Outbounds')}
</span>
{rule.outboundTag ? (
<Tag color="green" className="flow-tag">
<ExportOutlined aria-hidden="true" /> {rule.outboundTag}
</Tag>
) : rule.balancerTag ? (
<Tag color="purple" className="flow-tag">
<ClusterOutlined aria-hidden="true" /> {rule.balancerTag}
</Tag>
) : (
<span className="criterion-empty">—</span>
)}
</div>
</div>
{ruleCriteriaChips(rule).length > 0 && (
<div className="rule-criteria">
{ruleCriteriaChips(rule).map((chip) => (
<Tooltip key={chip.label} title={`${chip.label}: ${chip.value}`}>
<span className="criterion-chip">
<span className="criterion-chip-label">{chip.label}</span>
<span className="criterion-chip-value">{chipPreview(chip.value)}</span>
</span>
</Tooltip>
))}
</div>
)}
</div>
))
)}
</div>
);
}
@@ -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<string, string>;
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<string, unknown>) => 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<RuleFormValues>({ 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<string, unknown> = {
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<string, unknown> = {};
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 (
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
mask={{ closable: false }}
width={640}
onOk={submit}
onCancel={onClose}
>
<FormProvider {...methods}>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<FormField name="enabled" label={t('enable')} valueProp="checked">
<Switch disabled={isApiRule(rule ?? {})} />
</FormField>
<FormField
name="sourceIP"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<GeoTokenInput kind="ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</FormField>
<FormField
name="sourcePort"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="53,443,1000-2000" />
</FormField>
<FormField
name="vlessRoute"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="53,443,1000-2000" />
</FormField>
<FormField name="network" label={t('pages.inbounds.network')}>
<Select options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))} />
</FormField>
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
<Select mode="multiple" options={PROTOCOLS.map((p) => ({ value: p, label: p }))} />
</FormField>
<Form.Item label={t('pages.xray.ruleForm.attributes')}>
<Button
size="small"
aria-label={t('add')}
icon={<PlusOutlined />}
onClick={() => methods.setValue('attrs', [...attrs, ['', ''] as [string, string]])}
/>
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
{attrs.map((attr, idx) => (
<Space.Compact key={idx} block className="mb-8">
<InputAddon>{`${idx + 1}`}</InputAddon>
<Input
value={attr[0]}
aria-label={t('pages.nodes.name')}
placeholder={t('pages.nodes.name')}
onChange={(e) => {
const next = attrs.map((a, i) =>
i === idx ? ([e.target.value, a[1]] as [string, string]) : a,
);
methods.setValue('attrs', next);
}}
/>
<Input
value={attr[1]}
aria-label={t('pages.xray.ruleForm.value')}
placeholder={t('pages.xray.ruleForm.value')}
onChange={(e) => {
const next = attrs.map((a, i) =>
i === idx ? ([a[0], e.target.value] as [string, string]) : a,
);
methods.setValue('attrs', next);
}}
/>
<Button
aria-label={t('remove')}
icon={<MinusOutlined />}
onClick={() =>
methods.setValue(
'attrs',
attrs.filter((_, i) => i !== idx),
)
}
/>
</Space.Compact>
))}
</Form.Item>
<FormField
name="ip"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
IP <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<GeoTokenInput kind="ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
</FormField>
<FormField
name="domain"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<GeoTokenInput kind="domain" placeholder="google.com, geosite:cn" />
</FormField>
<FormField
name="user"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
transform={{
input: (value) => csv(typeof value === 'string' ? value : ''),
output: (value) => (Array.isArray(value) ? value.join(',') : ''),
}}
>
<Select
mode="tags"
tokenSeparators={[',']}
allowClear
loading={clientsLoading}
placeholder={t('pages.xray.ruleForm.userPlaceholder')}
showSearch={{ optionFilterProp: 'label' }}
notFoundContent={
clientsLoading
? t('loading')
: clientsError
? t('pages.xray.ruleForm.userLoadError')
: t('pages.xray.ruleForm.userEmpty')
}
options={userOptions}
/>
</FormField>
<FormField
name="port"
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Input placeholder="53,443,1000-2000" />
</FormField>
<FormField name="inboundTag" label={t('pages.xray.ruleForm.inboundTags')}>
<Select
mode="multiple"
options={inboundTags.map((tag) => ({
value: tag,
label: formatInboundTag(tag, remarkByTag),
}))}
/>
</FormField>
<FormField name="outboundTag" label={t('pages.xray.ruleForm.outboundTag')}>
<Select options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
</FormField>
<FormField
name="balancerTag"
label={
<Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
<Select options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
</FormField>
</Form>
</FormProvider>
</Modal>
);
}
-103
View File
@@ -1,103 +0,0 @@
import type { RuleRow } from './types';
export function arrJoin(v: unknown): string | undefined {
if (v == null) return undefined;
if (Array.isArray(v)) return v.join(',');
return String(v);
}
/**
* Translate a table row's positional index into that rule's index in the full,
* unfiltered routing.rules array. The table hides balancer-loopback rules but
* keeps each visible row's original index in `key`, so any handler that mutates
* routing.rules must map the positional index back through `key` or it operates
* on the wrong rule once a hidden loopback precedes it.
*/
export function originalRuleIndex(rows: RuleRow[], positionalIndex: number): number {
const row = rows[positionalIndex];
return row ? row.key : positionalIndex;
}
export function csv(value?: string): string[] {
if (!value) return [];
return String(value)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
export function chipPreviewParts(parts: string[]): string {
if (parts.length === 0) return '';
if (parts.length === 1) return parts[0];
return `${parts[0]} +${parts.length - 1}`;
}
export function chipPreview(value?: string): string {
return chipPreviewParts(csv(value));
}
/** Same lookup as RuleFormModal inbound select: remark first, else tag. */
export function buildRemarkByTag(
options: Array<{ tag?: string; remark?: string }>,
): Record<string, string> {
const map: Record<string, string> = {};
for (const ib of options) {
if (ib.tag) map[ib.tag] = ib.remark?.trim() || ib.tag;
}
return map;
}
/** Format a single inbound tag as `tag (remark)`, or just `tag` when no distinct remark. */
export function formatInboundTag(tag: string, remarkByTag: Record<string, string> = {}): string {
const label = remarkByTag[tag]?.trim();
if (!label || label === tag) return tag;
return `${tag} (${label})`;
}
/**
* Formatted inbound entries — `tag (remark)` when a distinct remark exists, else
* `tag`. Returns an array (not a joined string) so callers never have to re-split
* on commas, which a remark may legitimately contain.
*/
export function formatInboundTagList(
tags?: string,
remarkByTag: Record<string, string> = {},
): string[] {
return csv(tags).map((tag) => formatInboundTag(tag, remarkByTag));
}
export function inboundTagsDisplayTitle(
tags?: string,
remarkByTag: Record<string, string> = {},
): string | undefined {
const list = formatInboundTagList(tags, remarkByTag);
return list.length > 0 ? list.join(', ') : undefined;
}
export function inboundTagChipPreview(
tags?: string,
remarkByTag: Record<string, string> = {},
): string {
return chipPreviewParts(formatInboundTagList(tags, remarkByTag));
}
/** The internal api rule (stats traffic) — its enabled state must stay locked on. */
export function isApiRule(rule: { outboundTag?: string; inboundTag?: string | string[] }): boolean {
if (rule.outboundTag !== 'api') return false;
const tags = Array.isArray(rule.inboundTag) ? rule.inboundTag : csv(rule.inboundTag);
return tags.includes('api');
}
export function ruleCriteriaChips(rule: RuleRow) {
const chips: { label: string; value?: string }[] = [];
if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });
if (rule.ip) chips.push({ label: 'IP', value: rule.ip });
if (rule.port) chips.push({ label: 'Port', value: rule.port });
if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP });
if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort });
if (rule.network) chips.push({ label: 'L4', value: rule.network.toUpperCase() });
if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol });
if (rule.user) chips.push({ label: 'User', value: rule.user });
if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute });
return chips;
}
-2
View File
@@ -1,2 +0,0 @@
export { default as RoutingTab } from './RoutingTab';
export { default as RuleFormModal } from './RuleFormModal';
-17
View File
@@ -1,17 +0,0 @@
export interface RuleRow {
key: number;
enabled?: boolean;
domain?: string;
ip?: string;
port?: string;
sourcePort?: string;
vlessRoute?: string;
network?: string;
sourceIP?: string;
user?: string;
inboundTag?: string;
protocol?: string;
attrs?: string;
outboundTag?: string;
balancerTag?: string;
}
@@ -1,329 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Switch, Tag } from 'antd';
import {
MoreOutlined,
EditOutlined,
DeleteOutlined,
ExportOutlined,
ClusterOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
HolderOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import CriterionRow from './CriterionRow';
import {
buildRemarkByTag,
formatInboundTagList,
inboundTagsDisplayTitle,
isApiRule,
} from './helpers';
import type { RuleRow } from './types';
interface RoutingColumnsParams {
isMobile: boolean;
rowsLength: number;
showSource: boolean;
showBalancer: boolean;
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 function useRoutingColumns({
isMobile,
rowsLength,
showSource,
showBalancer,
onHandlePointerDown,
openEdit,
moveUp,
moveDown,
confirmDelete,
toggleRule,
}: RoutingColumnsParams): ColumnsType<RuleRow> {
const { t } = useTranslation();
const { data: inboundOptions } = useInboundOptions();
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
return useMemo(
() => [
{
title: '#',
align: 'center',
width: 60,
key: 'index',
render: (_v, _r, index) => (
<div className="action-cell" style={{ justifyContent: 'center' }}>
<HolderOutlined
className="drag-handle"
title={t('pages.xray.routing.dragToReorder')}
aria-hidden="true"
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
/>
<span className="row-index">{index + 1}</span>
</div>
),
},
{
title: t('pages.clients.actions'),
align: 'center',
width: 80,
key: 'action',
render: (_v, _r, index) => (
<div
className={!isMobile ? 'action-buttons' : ''}
style={{ justifyContent: 'center', margin: 0 }}
>
{!isMobile && (
<Button
shape="circle"
size="small"
icon={<EditOutlined />}
aria-label={t('edit')}
onClick={() => openEdit(index)}
/>
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEdit(index),
},
]
: []),
{
key: 'up',
label: (
<>
<ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}
</>
),
disabled: index === 0,
onClick: () => moveUp(index),
},
{
key: 'down',
label: (
<>
<ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}
</>
),
disabled: index === rowsLength - 1,
onClick: () => moveDown(index),
},
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => confirmDelete(index),
},
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
),
},
{
title: t('enable'),
align: 'center',
width: 80,
key: 'enabled',
render: (_v, _r, index) => (
<Switch
size="small"
checked={_r.enabled !== false}
onChange={(checked) => toggleRule(index, checked)}
disabled={isApiRule(_r)}
/>
),
},
{
title: t('pages.xray.rules.source'),
align: 'left',
width: 180,
key: 'source',
hidden: !showSource,
render: (_v, record) => (
<div className="criterion-flow">
{record.sourceIP && (
<CriterionRow
label="IP"
value={record.sourceIP}
title={`Source IP: ${record.sourceIP}`}
/>
)}
{record.sourcePort && (
<CriterionRow
label="Port"
value={record.sourcePort}
title={`Source port: ${record.sourcePort}`}
/>
)}
{record.vlessRoute && (
<CriterionRow
label="VLESS"
value={record.vlessRoute}
title={`VLESS route: ${record.vlessRoute}`}
/>
)}
{!record.sourceIP && !record.sourcePort && !record.vlessRoute && (
<span className="criterion-empty">—</span>
)}
</div>
),
},
{
title: t('pages.inbounds.network'),
align: 'left',
width: 180,
key: 'network',
render: (_v, record) => (
<div className="criterion-flow">
{record.network && (
<CriterionRow
label="L4"
value={record.network.toUpperCase()}
title={`L4: ${record.network.toUpperCase()}`}
/>
)}
{record.protocol && (
<CriterionRow
label="Protocol"
value={record.protocol}
title={`Protocol: ${record.protocol}`}
/>
)}
{record.attrs && (
<CriterionRow label="Attrs" value={record.attrs} title={`Attrs: ${record.attrs}`} />
)}
{!record.network && !record.protocol && !record.attrs && (
<span className="criterion-empty">—</span>
)}
</div>
),
},
{
title: t('pages.xray.rules.dest'),
align: 'left',
width: 200,
key: 'destination',
render: (_v, record) => (
<div className="criterion-flow">
{record.ip && (
<CriterionRow label="IP" value={record.ip} title={`Destination IP: ${record.ip}`} />
)}
{record.domain && (
<CriterionRow
label="Domain"
value={record.domain}
title={`Domain: ${record.domain}`}
/>
)}
{record.port && (
<CriterionRow
label="Port"
value={record.port}
title={`Destination port: ${record.port}`}
/>
)}
{!record.ip && !record.domain && !record.port && (
<span className="criterion-empty">—</span>
)}
</div>
),
},
{
title: t('pages.xray.Inbounds'),
align: 'left',
width: 180,
key: 'inbound',
render: (_v, record) => {
const inboundParts = formatInboundTagList(record.inboundTag, remarkByTag);
return (
<div className="criterion-flow">
{inboundParts.length > 0 && (
<CriterionRow
label="Tag"
values={inboundParts}
title={`Inbound tag: ${inboundTagsDisplayTitle(record.inboundTag, remarkByTag) ?? inboundParts.join(', ')}`}
/>
)}
{record.user && (
<CriterionRow label="User" value={record.user} title={`User: ${record.user}`} />
)}
{inboundParts.length === 0 && !record.user && (
<span className="criterion-empty">—</span>
)}
</div>
);
},
},
{
title: t('pages.xray.Outbounds'),
align: 'left',
width: 170,
key: 'outbound',
render: (_v, record) =>
record.outboundTag ? (
<div className="target-row">
<ExportOutlined className="target-icon" aria-hidden="true" />
<Tag color="green">{record.outboundTag}</Tag>
</div>
) : (
<span className="criterion-empty">—</span>
),
},
{
title: t('pages.xray.Balancers'),
align: 'left',
width: 150,
key: 'balancer',
hidden: !showBalancer,
render: (_v, record) =>
record.balancerTag ? (
<div className="target-row">
<ClusterOutlined className="target-icon" aria-hidden="true" />
<Tag color="purple">{record.balancerTag}</Tag>
</div>
) : (
<span className="criterion-empty">—</span>
),
},
],
[
t,
isMobile,
rowsLength,
showSource,
showBalancer,
remarkByTag,
onHandlePointerDown,
openEdit,
moveUp,
moveDown,
confirmDelete,
toggleRule,
],
);
}
-4
View File
@@ -10,7 +10,6 @@ const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
function withSuspense(node: React.ReactNode) {
return (
@@ -44,9 +43,6 @@ const routes: RouteObject[] = [
{ path: 'nodes', element: withSuspense(<NodesPage />) },
{ path: 'hosts', element: withSuspense(<HostsPage />) },
{ path: 'settings', element: withSuspense(<SettingsPage />) },
{ path: 'xray', element: withSuspense(<XrayPage />) },
{ path: 'outbound', element: withSuspense(<XrayPage />) },
{ path: 'routing', element: withSuspense(<XrayPage />) },
],
},
];
+1 -1
View File
@@ -34,7 +34,7 @@ export const AllSettingSchema = z
tgLang: z.string().optional(),
twoFactorEnable: z.boolean().optional(),
twoFactorToken: z.string().optional(),
xrayTemplateConfig: z.string().optional(),
trafficStatsSwitches: z.string().optional(),
subEnable: z.boolean().optional(),
subJsonEnable: z.boolean().optional(),
subJsonAutoDetect: z.boolean().optional(),
-108
View File
@@ -1,108 +0,0 @@
import { z } from 'zod';
import { DnsObjectSchema } from './dns';
import {
BalancerObjectSchema,
BalancerStrategySettingsSchema,
BalancerStrategyTypeSchema,
RuleObjectSchema,
} from './routing';
export const XraySettingsValueSchema = z
.object({
inbounds: z.array(z.unknown()).optional(),
outbounds: z
.array(
z
.object({
tag: z.string().optional(),
protocol: z.string().optional(),
settings: z.unknown().optional(),
streamSettings: z.unknown().optional(),
})
.loose(),
)
.optional(),
routing: z
.object({
rules: z.array(RuleObjectSchema).optional(),
balancers: z.array(BalancerObjectSchema).optional(),
domainStrategy: z.string().optional(),
})
.loose()
.optional(),
dns: DnsObjectSchema.optional(),
log: z.record(z.string(), z.unknown()).optional(),
policy: z
.object({
system: z.record(z.string(), z.boolean()).optional(),
levels: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
})
.loose()
.optional(),
observatory: z.unknown().optional(),
burstObservatory: z.unknown().optional(),
fakedns: z.unknown().optional(),
})
.loose();
export const XrayConfigPayloadSchema = z
.object({
xraySetting: XraySettingsValueSchema,
inboundTags: z.array(z.string()).optional(),
clientReverseTags: z.array(z.string()).optional(),
// Subscription outbounds are injected at runtime (not persisted in xraySetting).
// They are provided here so the UI can display them and use their tags in
// balancers / routing rules.
subscriptionOutbounds: z.array(z.unknown()).optional(),
subscriptionOutboundTags: z.array(z.string()).optional(),
})
.loose();
export const OutboundTrafficRowSchema = z.object({
tag: z.string(),
up: z.number(),
down: z.number(),
});
export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
export const RuleFormSchema = z.object({
enabled: z.boolean(),
domain: z.string(),
ip: z.string(),
port: z.string(),
sourcePort: z.string(),
vlessRoute: z.string(),
network: z.string(),
sourceIP: z.string(),
user: z.string(),
inboundTag: z.array(z.string()),
protocol: z.array(z.string()),
attrs: z.array(z.tuple([z.string(), z.string()])),
outboundTag: z.string(),
balancerTag: z.string(),
});
export const BalancerFormSchema = z.object({
tag: z
.string()
.trim()
.min(1, 'pages.xray.balancerTagRequired')
.refine((val) => !val.startsWith('_bl_'), { message: 'pages.xray.balancer.reservedPrefix' }),
strategy: BalancerStrategyTypeSchema.default('random'),
selector: z.array(z.string()).min(1, 'pages.xray.balancerSelectorRequired'),
fallbackTag: z.string().default(''),
settings: BalancerStrategySettingsSchema.optional(),
});
export const OutboundTagSchema = z
.string()
.trim()
.min(1, 'pages.xray.outboundTagRequired')
.refine((val) => !val.startsWith('_bl_'), { message: 'pages.xray.balancer.reservedPrefix' });
export type BalancerFormValues = z.infer<typeof BalancerFormSchema>;
export type RuleFormValues = z.infer<typeof RuleFormSchema>;
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
export type OutboundTrafficRow = z.infer<typeof OutboundTrafficRowSchema>;
-385
View File
@@ -1,385 +0,0 @@
import { describe, it, expect } from 'vitest';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import {
isBalancerLoopbackTag,
loopbackTagFor,
balancerTagFromLoopback,
resolveLoopbackFallback,
ensureBalancerLoopback,
ensureMissingBalancerLoopbacks,
removeBalancerLoopback,
removeBalancerLoopbackIfOrphaned,
propagateBalancerTagRename,
detectBalancerCycles,
cleanupOrphanedBalancerLoopbacks,
} from '@/pages/xray/balancers/balancer-loopback';
interface OutboundEntry {
tag?: string;
protocol?: string;
settings?: { inboundTag?: string };
}
interface RuleEntry {
type?: string;
inboundTag?: string[];
balancerTag?: string;
domain?: string[];
}
interface BalancerEntry {
tag?: string;
selector?: string[];
fallbackTag?: string;
}
function makeSettings(input: {
outbounds?: OutboundEntry[];
rules?: RuleEntry[];
balancers?: BalancerEntry[];
}): XraySettingsValue {
return {
outbounds: input.outbounds,
routing: {
rules: input.rules,
balancers: input.balancers,
},
} as XraySettingsValue;
}
function outboundTags(settings: XraySettingsValue): string[] {
return ((settings.outbounds ?? []) as OutboundEntry[]).map((o) => o.tag ?? '');
}
function loopbackOutbounds(settings: XraySettingsValue): OutboundEntry[] {
return ((settings.outbounds ?? []) as OutboundEntry[]).filter((o) => o.protocol === 'loopback');
}
function ruleEntries(settings: XraySettingsValue): RuleEntry[] {
return (settings.routing?.rules ?? []) as RuleEntry[];
}
function balancerEntries(settings: XraySettingsValue): BalancerEntry[] {
return (settings.routing?.balancers ?? []) as BalancerEntry[];
}
describe('loopback tag helpers', () => {
const cases: Array<{ tag: string; isLoopback: boolean; roundtrip: string | null }> = [
{ tag: '_bl_main', isLoopback: true, roundtrip: 'main' },
{ tag: 'main', isLoopback: false, roundtrip: null },
{ tag: '_bl_', isLoopback: true, roundtrip: '' },
{ tag: 'proxy_bl_', isLoopback: false, roundtrip: null },
];
it.each(cases)('classifies $tag', ({ tag, isLoopback, roundtrip }) => {
expect(isBalancerLoopbackTag(tag)).toBe(isLoopback);
expect(balancerTagFromLoopback(tag)).toBe(roundtrip);
});
it('builds a loopback tag that round-trips back to the balancer tag', () => {
expect(loopbackTagFor('cluster-a')).toBe('_bl_cluster-a');
expect(balancerTagFromLoopback(loopbackTagFor('cluster-a'))).toBe('cluster-a');
});
});
describe('resolveLoopbackFallback', () => {
const settings = makeSettings({
rules: [{ type: 'field', inboundTag: ['_bl_bal1'], balancerTag: 'bal1' }],
});
const cases: Array<{ name: string; input: string; expected: string }> = [
{
name: 'resolves a loopback tag through its routing rule',
input: '_bl_bal1',
expected: 'bal1',
},
{ name: 'returns a plain outbound tag unchanged', input: 'direct', expected: 'direct' },
{ name: 'returns an empty tag unchanged', input: '', expected: '' },
{ name: 'derives the balancer tag when no rule maps it', input: '_bl_bal2', expected: 'bal2' },
];
it.each(cases)('$name', ({ input, expected }) => {
expect(resolveLoopbackFallback(settings, input)).toBe(expected);
});
});
describe('ensureBalancerLoopback dedup', () => {
it('creates exactly one loopback outbound and one rule when called repeatedly', () => {
const settings = makeSettings({ outbounds: [], rules: [], balancers: [] });
ensureBalancerLoopback(settings, 'bal1');
ensureBalancerLoopback(settings, 'bal1');
const loopbacks = loopbackOutbounds(settings);
expect(loopbacks).toHaveLength(1);
expect(loopbacks[0]).toEqual({
tag: '_bl_bal1',
protocol: 'loopback',
settings: { inboundTag: '_bl_bal1' },
});
const matchingRules = ruleEntries(settings).filter(
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_bal1'),
);
expect(matchingRules).toHaveLength(1);
expect(matchingRules[0].balancerTag).toBe('bal1');
});
it('does not duplicate a loopback shared by multiple balancers', () => {
const settings = makeSettings({
outbounds: [],
rules: [],
balancers: [
{ tag: 'A', selector: [], fallbackTag: '_bl_shared' },
{ tag: 'B', selector: [], fallbackTag: '_bl_shared' },
],
});
ensureMissingBalancerLoopbacks(settings);
expect(loopbackOutbounds(settings)).toHaveLength(1);
expect(
ruleEntries(settings).filter(
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_shared'),
),
).toHaveLength(1);
});
});
describe('ensureBalancerLoopback rule ordering', () => {
function loopbackRuleIndex(settings: XraySettingsValue, lbTag: string): number {
return ruleEntries(settings).findIndex(
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes(lbTag),
);
}
function generalRuleIndex(settings: XraySettingsValue): number {
return ruleEntries(settings).findIndex(
(r) => !Array.isArray(r.inboundTag) || r.inboundTag.length === 0,
);
}
it('inserts a new loopback rule ahead of a general (no inboundTag) rule', () => {
const settings = makeSettings({
rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }],
balancers: [{ tag: 'parent', selector: [] }],
});
ensureBalancerLoopback(settings, 'target');
expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(generalRuleIndex(settings));
});
it('repositions an existing loopback rule that landed after a general rule', () => {
const settings = makeSettings({
rules: [
{ type: 'field', domain: ['example.com'], balancerTag: 'parent' },
{ type: 'field', inboundTag: ['_bl_target'], balancerTag: 'stale' },
],
balancers: [{ tag: 'parent', selector: [] }],
});
ensureBalancerLoopback(settings, 'target');
const lbIdx = loopbackRuleIndex(settings, '_bl_target');
expect(lbIdx).toBeLessThan(generalRuleIndex(settings));
expect(ruleEntries(settings)[lbIdx].balancerTag).toBe('target');
});
it('leaves inboundTag-restricted rules in place and slots loopback ahead of general rules only', () => {
const settings = makeSettings({
rules: [
{ type: 'field', inboundTag: ['api'], balancerTag: 'stats' },
{ type: 'field', domain: ['example.com'], balancerTag: 'parent' },
],
balancers: [{ tag: 'parent', selector: [] }],
});
ensureBalancerLoopback(settings, 'target');
const entries = ruleEntries(settings);
expect(entries[0].inboundTag).toEqual(['api']);
const lbIdx = loopbackRuleIndex(settings, '_bl_target');
const generalIdx = generalRuleIndex(settings);
expect(lbIdx).toBeLessThan(generalIdx);
expect(lbIdx).toBeGreaterThan(0);
});
it('ensureMissingBalancerLoopbacks repositions every mis-ordered loopback rule', () => {
const settings = makeSettings({
rules: [
{ type: 'field', domain: ['example.com'], balancerTag: 'B1' },
{ type: 'field', inboundTag: ['_bl_B2'], balancerTag: 'B2' },
],
balancers: [
{ tag: 'B1', selector: [], fallbackTag: '_bl_B2' },
{ tag: 'B2', selector: [] },
],
});
ensureMissingBalancerLoopbacks(settings);
expect(loopbackRuleIndex(settings, '_bl_B2')).toBeLessThan(generalRuleIndex(settings));
});
it('keeps the loopback rule ahead of the general rule after a second ensureBalancerLoopback call', () => {
const settings = makeSettings({
rules: [{ type: 'field', domain: ['example.com'], balancerTag: 'parent' }],
balancers: [{ tag: 'parent', selector: [] }],
});
ensureBalancerLoopback(settings, 'target');
ensureBalancerLoopback(settings, 'target');
expect(loopbackRuleIndex(settings, '_bl_target')).toBeLessThan(generalRuleIndex(settings));
expect(
ruleEntries(settings).filter(
(r) => Array.isArray(r.inboundTag) && r.inboundTag.includes('_bl_target'),
),
).toHaveLength(1);
});
});
describe('detectBalancerCycles', () => {
const cases: Array<{ name: string; balancers: BalancerEntry[]; expected: string[][] }> = [
{
name: 'two-balancer loop A -> B -> A',
balancers: [
{ tag: 'A', fallbackTag: '_bl_B' },
{ tag: 'B', fallbackTag: '_bl_A' },
],
expected: [
['A', 'B'],
['B', 'A'],
],
},
{
name: 'self loop A -> A',
balancers: [{ tag: 'A', fallbackTag: '_bl_A' }],
expected: [['A', 'A']],
},
{
name: 'three-balancer loop A -> B -> C -> A',
balancers: [
{ tag: 'A', fallbackTag: '_bl_B' },
{ tag: 'B', fallbackTag: '_bl_C' },
{ tag: 'C', fallbackTag: '_bl_A' },
],
expected: [
['A', 'B'],
['B', 'C'],
['C', 'A'],
],
},
{
name: 'linear chain is not a cycle',
balancers: [{ tag: 'A', fallbackTag: '_bl_B' }, { tag: 'B' }],
expected: [],
},
{
name: 'non-loopback fallback is ignored',
balancers: [{ tag: 'A', fallbackTag: 'direct' }],
expected: [],
},
];
it.each(cases)('$name', ({ balancers, expected }) => {
expect(detectBalancerCycles(makeSettings({ balancers }))).toEqual(expected);
});
});
describe('propagateBalancerTagRename', () => {
it('rewrites the loopback outbound, its rule and referring fallback tags', () => {
const settings = makeSettings({
outbounds: [{ tag: '_bl_old', protocol: 'loopback', settings: { inboundTag: '_bl_old' } }],
rules: [{ type: 'field', inboundTag: ['_bl_old'], balancerTag: 'old' }],
balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_old' }],
});
propagateBalancerTagRename(settings, 'old', 'new');
const [outbound] = loopbackOutbounds(settings);
expect(outbound.tag).toBe('_bl_new');
expect(outbound.settings?.inboundTag).toBe('_bl_new');
expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_new']);
expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_new');
});
it('leaves unrelated loopback tags untouched', () => {
const settings = makeSettings({
outbounds: [
{ tag: '_bl_other', protocol: 'loopback', settings: { inboundTag: '_bl_other' } },
],
rules: [{ type: 'field', inboundTag: ['_bl_other'], balancerTag: 'other' }],
balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_other' }],
});
propagateBalancerTagRename(settings, 'old', 'new');
expect(loopbackOutbounds(settings)[0].tag).toBe('_bl_other');
expect(ruleEntries(settings)[0].inboundTag).toEqual(['_bl_other']);
expect(balancerEntries(settings)[0].fallbackTag).toBe('_bl_other');
});
});
describe('orphan cleanup', () => {
it('cleanupOrphanedBalancerLoopbacks removes only unreferenced loopbacks', () => {
const settings = makeSettings({
outbounds: [
{ tag: '_bl_gone', protocol: 'loopback', settings: { inboundTag: '_bl_gone' } },
{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } },
{ tag: 'proxy', protocol: 'vless' },
],
rules: [
{ type: 'field', inboundTag: ['_bl_gone'], balancerTag: 'gone' },
{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' },
],
balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
});
cleanupOrphanedBalancerLoopbacks(settings);
expect(outboundTags(settings)).toEqual(['_bl_kept', 'proxy']);
expect(ruleEntries(settings).map((r) => r.inboundTag)).toEqual([['_bl_kept']]);
});
it('removeBalancerLoopbackIfOrphaned keeps a loopback that is still referenced', () => {
const settings = makeSettings({
outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }],
rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
});
removeBalancerLoopbackIfOrphaned(settings, 'kept');
expect(loopbackOutbounds(settings)).toHaveLength(1);
expect(ruleEntries(settings)).toHaveLength(1);
});
it('removeBalancerLoopbackIfOrphaned drops a loopback with no referrers', () => {
const settings = makeSettings({
outbounds: [{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } }],
rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
balancers: [],
});
removeBalancerLoopbackIfOrphaned(settings, 'kept');
expect(loopbackOutbounds(settings)).toHaveLength(0);
expect(ruleEntries(settings)).toHaveLength(0);
});
it('removeBalancerLoopback deletes the outbound and rule directly', () => {
const settings = makeSettings({
outbounds: [
{ tag: '_bl_kept', protocol: 'loopback', settings: { inboundTag: '_bl_kept' } },
{ tag: 'proxy', protocol: 'vless' },
],
rules: [{ type: 'field', inboundTag: ['_bl_kept'], balancerTag: 'kept' }],
balancers: [{ tag: 'user', selector: [], fallbackTag: '_bl_kept' }],
});
removeBalancerLoopback(settings, 'kept');
expect(outboundTags(settings)).toEqual(['proxy']);
expect(ruleEntries(settings)).toHaveLength(0);
});
});
@@ -1,455 +0,0 @@
import { describe, expect, it } from 'vitest';
import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
function tpl(
routing: Record<string, unknown>,
extra: Record<string, unknown> = {},
): XraySettingsValue {
return { routing, ...extra } as unknown as XraySettingsValue;
}
type ExpectedObserver = 'none' | 'observatory' | 'burstObservatory';
function expectObserver(
t: XraySettingsValue,
expected: ExpectedObserver,
selectors: string[] = [],
) {
if (expected === 'none') {
expect(t.observatory).toBeUndefined();
expect(t.burstObservatory).toBeUndefined();
return;
}
if (expected === 'observatory') {
expect(t.observatory).toBeDefined();
expect(t.burstObservatory).toBeUndefined();
expect(new Set((t.observatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
new Set(selectors),
);
return;
}
expect(t.observatory).toBeUndefined();
expect(t.burstObservatory).toBeDefined();
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
new Set(selectors),
);
}
// Observatory sections have no reload API in xray-core, so creating one turns
// a balancer save from a live (hot-applied) routing change into a full
// restart. These tests pin the rule: only strategies that genuinely need an
// observer may create one — which, for random/roundRobin, means a fallbackTag
// is set (xray-core then requires the Observatory feature; see #5605).
describe('syncObservatories', () => {
it.each([
{
name: 'random without fallback',
balancers: [{ tag: 'random', selector: ['random-out'] }],
expected: 'none' as const,
selectors: [],
},
{
name: 'random with fallback',
balancers: [{ tag: 'random', selector: ['random-out'], fallbackTag: 'direct' }],
expected: 'burstObservatory' as const,
selectors: ['random-out'],
},
{
name: 'roundRobin without fallback',
balancers: [{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }],
expected: 'none' as const,
selectors: [],
},
{
name: 'roundRobin with fallback',
balancers: [
{
tag: 'rr',
selector: ['rr-out'],
fallbackTag: 'direct',
strategy: { type: 'roundRobin' },
},
],
expected: 'burstObservatory' as const,
selectors: ['rr-out'],
},
{
name: 'leastPing without fallback',
balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }],
expected: 'observatory' as const,
selectors: ['lp-out'],
},
{
name: 'leastPing with fallback',
balancers: [
{ tag: 'lp', selector: ['lp-out'], fallbackTag: 'direct', strategy: { type: 'leastPing' } },
],
expected: 'observatory' as const,
selectors: ['lp-out'],
},
{
name: 'leastLoad without fallback',
balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }],
expected: 'burstObservatory' as const,
selectors: ['ll-out'],
},
{
name: 'leastLoad with fallback',
balancers: [
{ tag: 'll', selector: ['ll-out'], fallbackTag: 'direct', strategy: { type: 'leastLoad' } },
],
expected: 'burstObservatory' as const,
selectors: ['ll-out'],
},
])('covers standalone strategy: $name', ({ balancers, expected, selectors }) => {
const t = tpl({ balancers });
syncObservatories(t);
expectObserver(t, expected, selectors);
});
it.each([
{
name: 'random + roundRobin without fallbacks',
balancers: [
{ tag: 'random', selector: ['random-out'] },
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
],
expected: 'none' as const,
selectors: [],
},
{
name: 'leastPing + random without fallback',
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'random', selector: ['random-out'] },
],
expected: 'observatory' as const,
selectors: ['lp-out'],
},
{
name: 'leastPing + roundRobin without fallback',
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
],
expected: 'observatory' as const,
selectors: ['lp-out'],
},
{
name: 'random fallback + random without fallback',
balancers: [
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
{ tag: 'random', selector: ['random-out'] },
],
expected: 'burstObservatory' as const,
selectors: ['random-fallback-out'],
},
{
name: 'roundRobin fallback + roundRobin without fallback',
balancers: [
{
tag: 'rrf',
selector: ['rr-fallback-out'],
fallbackTag: 'direct',
strategy: { type: 'roundRobin' },
},
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
],
expected: 'burstObservatory' as const,
selectors: ['rr-fallback-out'],
},
{
name: 'leastLoad + random without fallback',
balancers: [
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
{ tag: 'random', selector: ['random-out'] },
],
expected: 'burstObservatory' as const,
selectors: ['ll-out'],
},
{
name: 'leastLoad + roundRobin without fallback',
balancers: [
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
],
expected: 'burstObservatory' as const,
selectors: ['ll-out'],
},
{
name: 'leastPing + leastLoad with disjoint selectors',
balancers: [
{ tag: 'lp', selector: ['lp-out', 'direct'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['ll-out-1', 'll-out-2'], strategy: { type: 'leastLoad' } },
],
expected: 'burstObservatory' as const,
selectors: ['lp-out', 'direct', 'll-out-1', 'll-out-2'],
},
{
name: 'leastPing + random fallback',
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
],
expected: 'burstObservatory' as const,
selectors: ['lp-out', 'random-fallback-out'],
},
{
name: 'leastPing + roundRobin fallback',
balancers: [
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{
tag: 'rrf',
selector: ['rr-fallback-out'],
fallbackTag: 'direct',
strategy: { type: 'roundRobin' },
},
],
expected: 'burstObservatory' as const,
selectors: ['lp-out', 'rr-fallback-out'],
},
{
name: 'all strategies mixed',
balancers: [
{ tag: 'random', selector: ['random-out'] },
{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
{
tag: 'rrf',
selector: ['rr-fallback-out'],
fallbackTag: 'direct',
strategy: { type: 'roundRobin' },
},
{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
],
expected: 'burstObservatory' as const,
selectors: ['random-fallback-out', 'rr-fallback-out', 'lp-out', 'll-out'],
},
{
name: 'shared selectors are de-duplicated',
balancers: [
{ tag: 'lp', selector: ['shared', 'lp-only'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['shared', 'll-only'], strategy: { type: 'leastLoad' } },
{ tag: 'rf', selector: ['shared', 'rf-only'], fallbackTag: 'direct' },
],
expected: 'burstObservatory' as const,
selectors: ['shared', 'lp-only', 'll-only', 'rf-only'],
},
])('covers mixed strategy matrix: $name', ({ balancers, expected, selectors }) => {
const t = tpl({ balancers });
syncObservatories(t);
expectObserver(t, expected, selectors);
});
it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => {
const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] });
syncObservatories(t);
expect(t.burstObservatory).toBeUndefined();
expect(t.observatory).toBeUndefined();
});
it('does not create burstObservatory for roundRobin without fallback', () => {
const t = tpl({
balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }],
});
syncObservatories(t);
expect(t.burstObservatory).toBeUndefined();
});
it('creates burstObservatory for a random balancer with a fallbackTag (#5605)', () => {
const t = tpl({
balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: 'warp' }],
});
syncObservatories(t);
expect(t.burstObservatory).toBeDefined();
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual([
'opera-proxy',
]);
});
it('creates burstObservatory for roundRobin with a fallbackTag', () => {
const t = tpl({
balancers: [
{ tag: 'b1', selector: ['a'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } },
],
});
syncObservatories(t);
expect(t.burstObservatory).toBeDefined();
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
});
it('treats an empty-string fallbackTag as no fallback (stays hot-appliable)', () => {
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: '' }] });
syncObservatories(t);
expect(t.burstObservatory).toBeUndefined();
});
it('removes burstObservatory when a random balancer drops its fallbackTag', () => {
const t = tpl(
{ balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: '' }] },
{ burstObservatory: { subjectSelector: ['opera-proxy'] } },
);
syncObservatories(t);
expect(t.burstObservatory).toBeUndefined();
});
it('removes burstObservatory when a roundRobin balancer drops its fallbackTag', () => {
const t = tpl(
{ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] },
{ burstObservatory: { subjectSelector: ['a'] } },
);
syncObservatories(t);
expect(t.burstObservatory).toBeUndefined();
});
it('keeps burstObservatory while another fallback balancer still needs it without adding no-fallback selectors', () => {
const t = tpl(
{
balancers: [
{ tag: 'b1', selector: ['a'] },
{ tag: 'b2', selector: ['b'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } },
],
},
{ burstObservatory: { subjectSelector: ['a', 'b'] } },
);
syncObservatories(t);
expect(t.burstObservatory).toBeDefined();
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
});
it('creates burstObservatory for leastLoad (required by the strategy)', () => {
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
syncObservatories(t);
expect(t.burstObservatory).toBeDefined();
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
});
it('creates observatory for leastPing when no burst observer is required', () => {
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
syncObservatories(t);
expect(t.observatory).toBeDefined();
expect((t.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
});
it('uses only burstObservatory when leastPing is mixed with leastLoad', () => {
const t = tpl(
{
balancers: [
{ tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
{ tag: 'll', selector: ['least-load-out'], strategy: { type: 'leastLoad' } },
],
},
{ observatory: { subjectSelector: ['stale-least-ping-out'] } },
);
syncObservatories(t);
expect(t.observatory).toBeUndefined();
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
new Set(['least-load-out', 'least-ping-out']),
);
});
it('uses only burstObservatory when leastPing is mixed with fallback balancers', () => {
const t = tpl(
{
balancers: [
{ tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
{ tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
{
tag: 'rr',
selector: ['round-robin-fallback-out'],
fallbackTag: 'direct',
strategy: { type: 'roundRobin' },
},
],
},
{ observatory: { subjectSelector: ['stale-least-ping-out'] } },
);
syncObservatories(t);
expect(t.observatory).toBeUndefined();
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
new Set(['random-fallback-out', 'round-robin-fallback-out', 'least-ping-out']),
);
});
it('does not keep no-fallback random selectors in an observer created by another balancer', () => {
const t = tpl(
{
balancers: [
{ tag: 'b1', selector: ['a'] },
{ tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } },
],
},
{ burstObservatory: { subjectSelector: ['stale'] } },
);
syncObservatories(t);
expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
});
it('removes observatories when no balancer can use them', () => {
const t = tpl(
{ balancers: [] },
{
observatory: { subjectSelector: ['a'] },
burstObservatory: { subjectSelector: ['a'] },
},
);
syncObservatories(t);
expect(t.observatory).toBeUndefined();
expect(t.burstObservatory).toBeUndefined();
});
it('switches from stale burstObservatory back to regular observatory when only leastPing remains', () => {
const t = tpl(
{ balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }] },
{
observatory: { subjectSelector: ['stale-observatory-out'] },
burstObservatory: { subjectSelector: ['stale-burst-out'] },
},
);
syncObservatories(t);
expectObserver(t, 'observatory', ['lp-out']);
});
it('switches from stale observatory to burstObservatory when any burst strategy remains', () => {
const t = tpl(
{ balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }] },
{
observatory: { subjectSelector: ['stale-observatory-out'] },
burstObservatory: { subjectSelector: ['stale-burst-out'] },
},
);
syncObservatories(t);
expectObserver(t, 'burstObservatory', ['ll-out']);
});
it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => {
const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
syncObservatories(t);
const burst = t.burstObservatory as { pingConfig: { httpMethod: string; sampling: number } };
expect(burst.pingConfig.httpMethod).toBe('HEAD');
expect(burst.pingConfig.sampling).toBe(2);
});
it('drops only the prefixes no remaining balancer uses (note #2)', () => {
const t = tpl({
balancers: [
{ tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
{ tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
],
});
syncObservatories(t);
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
new Set(['prefixA', 'prefixB', 'prefixC']),
);
(t.routing as { balancers: unknown[] }).balancers.splice(0, 1);
syncObservatories(t);
expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
new Set(['prefixC', 'prefixB']),
);
});
});
@@ -1,34 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import BasicsTab from '@/pages/xray/basics/BasicsTab';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { renderWithProviders } from './test-utils';
function settingsWithMalformedHappyEyeballs(): XraySettingsValue {
return {
outbounds: [
{
protocol: 'freedom',
tag: 'direct',
streamSettings: { sockopt: { happyEyeballs: { tryDelayMs: 'fast' } } },
},
],
} as unknown as XraySettingsValue;
}
describe('BasicsTab malformed happyEyeballs', () => {
it('renders instead of white-screening on a wrong-typed happyEyeballs value', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() =>
renderWithProviders(
<BasicsTab
templateSettings={settingsWithMalformedHappyEyeballs()}
setTemplateSettings={vi.fn()}
onResetDefault={vi.fn()}
/>,
),
).not.toThrow();
errorSpy.mockRestore();
});
});
@@ -1,260 +0,0 @@
import type { ReactNode } from 'react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
import { afterEach, describe, expect, it, vi } from 'vitest';
import GeoBrowserModal from '@/components/geodata/GeoBrowserModal';
import GeoTokenInput from '@/components/geodata/GeoTokenInput';
import { makeTestQueryClient } from '@/test/test-utils';
import { HttpUtil, Msg } from '@/utils';
afterEach(() => {
vi.restoreAllMocks();
});
const FILES = [
{ name: 'geosite.dat', kind: 'site', size: 1024, modifiedAt: 1785428467270, categories: 3 },
];
const IP_FILE = {
name: 'geoip.dat',
kind: 'ip',
size: 2048,
modifiedAt: 1785428467270,
categories: 1,
};
const IP_CATEGORIES = { total: 1, items: [{ code: 'private', entries: 1, attributes: [] }] };
const CATEGORIES = {
total: 3,
items: [
{ code: 'cn', entries: 2, attributes: [] },
{ code: 'google', entries: 2, attributes: ['ads'] },
{ code: 'telegram', entries: 1, attributes: [] },
],
};
function mockGeodata(files: unknown[] = FILES) {
const get = vi
.spyOn(HttpUtil, 'get')
.mockImplementation(async (url: string, params?: unknown) => {
const requestedFile = (params as { file?: string } | undefined)?.file;
if (url.includes('/geodata/files')) return new Msg(true, '', files);
if (url.includes('/geodata/categories')) {
return new Msg(true, '', requestedFile === 'geoip.dat' ? IP_CATEGORIES : CATEGORIES);
}
if (url.includes('/geodata/entries')) return new Msg(true, '', { total: 0, items: [] });
return new Msg(true, '', null);
});
vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', []));
return get;
}
type GetSpy = ReturnType<typeof mockGeodata>;
function entryFilters(get: GetSpy): string[] {
return get.mock.calls
.filter(([url]) => String(url).includes('/geodata/entries'))
.map(([, params]) => (params as { q?: string } | undefined)?.q ?? '');
}
function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>;
}
async function checkboxFor(code: string) {
const cell = await screen.findByText(code);
const row = cell.closest('.ant-table-row');
if (!row) throw new Error(`row for ${code} not found`);
return within(row as HTMLElement).getByRole('checkbox') as HTMLInputElement;
}
describe('GeoBrowserModal selection', () => {
it('seeds the selection from the field every time it opens', async () => {
mockGeodata();
const view = render(
<GeoBrowserModal
open
kind="site"
value="geosite:google"
onApply={vi.fn()}
onClose={vi.fn()}
/>,
{ wrapper },
);
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
view.rerender(
<GeoBrowserModal
open={false}
kind="site"
value="geosite:google"
onApply={vi.fn()}
onClose={vi.fn()}
/>,
);
view.rerender(
<GeoBrowserModal
open
kind="site"
value="geosite:google"
onApply={vi.fn()}
onClose={vi.fn()}
/>,
);
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
expect((await checkboxFor('cn')).checked).toBe(false);
});
it('keeps selections that the search box has filtered out of view', async () => {
mockGeodata();
const user = userEvent.setup();
const onApply = vi.fn();
render(<GeoBrowserModal open kind="site" value="" onApply={onApply} onClose={vi.fn()} />, {
wrapper,
});
await user.click(await checkboxFor('google'));
await user.type(screen.getByPlaceholderText(/search category|поиск категории/i), 'cn');
await waitFor(() => expect(screen.queryByText('google')).toBeNull());
await user.click(await checkboxFor('cn'));
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
expect(onApply).toHaveBeenCalledTimes(1);
const applied = String(onApply.mock.calls[0][0]);
expect(
applied
.split(',')
.map((token) => token.trim())
.sort(),
).toEqual(['geosite:cn', 'geosite:google']);
});
it('drops a category from the field when its checkbox is cleared', async () => {
mockGeodata();
const user = userEvent.setup();
const onApply = vi.fn();
render(
<GeoBrowserModal
open
kind="site"
value="google.com, geosite:google, geosite:blabla"
onApply={onApply}
onClose={vi.fn()}
/>,
{ wrapper },
);
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
await user.click(await checkboxFor('google'));
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
expect(onApply).toHaveBeenCalledWith('google.com, geosite:blabla');
});
it('offers only databases matching the field kind', async () => {
mockGeodata([...FILES, IP_FILE]);
render(<GeoBrowserModal open kind="ip" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
wrapper,
});
await screen.findByText('private');
expect(screen.getByTitle('geoip.dat')).toBeTruthy();
expect(screen.queryByText('google')).toBeNull();
});
it('does not seed one database from another database categories', async () => {
mockGeodata([...FILES, IP_FILE]);
const user = userEvent.setup();
render(
<GeoBrowserModal open kind="site" value="geosite:cn" onApply={vi.fn()} onClose={vi.fn()} />,
{ wrapper },
);
await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
await user.click(await checkboxFor('google'));
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
expect(screen.queryByText('private')).toBeNull();
});
it('waits for the entry filter to settle instead of querying every keystroke', async () => {
const get = mockGeodata();
const user = userEvent.setup({ delay: null });
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
wrapper,
});
await user.click(await screen.findByText('cn'));
await waitFor(() => expect(entryFilters(get)).toEqual(['']));
await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
expect(entryFilters(get)).toEqual(['']);
await waitFor(() => expect(entryFilters(get)).toEqual(['', 'abcd']), { timeout: 3000 });
});
it('drops the pending filter when another category is opened', async () => {
const get = mockGeodata();
const user = userEvent.setup({ delay: null });
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, {
wrapper,
});
await user.click(await screen.findByText('cn'));
await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
await user.click(screen.getByText('telegram'));
await new Promise((resolve) => setTimeout(resolve, 800));
expect(entryFilters(get)).toEqual(['', '']);
});
it('ticks and unticks a category written in its long ext form', async () => {
mockGeodata();
const user = userEvent.setup();
const onApply = vi.fn();
render(
<GeoBrowserModal
open
kind="site"
value="ext:geosite.dat:cn, google.com"
onApply={onApply}
onClose={vi.fn()}
/>,
{ wrapper },
);
await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
await user.click(await checkboxFor('cn'));
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
expect(onApply).toHaveBeenCalledWith('google.com');
});
});
describe('GeoTokenInput validation feedback', () => {
it('says the check failed instead of dropping the warnings silently', async () => {
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
vi.spyOn(HttpUtil, 'post')
.mockResolvedValueOnce(
new Msg(true, '', [{ token: 'geosite:nope', reason: 'categoryMissing' }]),
)
.mockResolvedValue(new Msg(false, 'too many tokens'));
const view = render(<GeoTokenInput kind="domain" value="geosite:nope" />, { wrapper });
await screen.findByText(/Not in the database/, {}, { timeout: 3000 });
view.rerender(<GeoTokenInput kind="domain" value="geosite:nope, geosite:other" />);
await screen.findByText(
'Could not check these values against the geo databases',
{},
{ timeout: 3000 },
);
expect(screen.queryByText(/Not in the database/)).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More