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:
@@ -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 : [];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+13
-3
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.xray-page .advanced-meta h4 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.xray-page .advanced-meta p {
|
||||
margin: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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 +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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as RoutingTab } from './RoutingTab';
|
||||
export { default as RuleFormModal } from './RuleFormModal';
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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 />) },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
Reference in New Issue
Block a user