精简版第四轮补裁(P4): 页面层摘除 + Xray更新端点下线 + 本机核心监管下线
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s

页面层(用户真机截图发现前两轮漏裁):
- 设置: 摘除 Telegram机器人/邮件菜单与页面, 及页内残留入口(通知页/外部流量/LDAP/2FA设置页)
- Xray配置: 摘除负载均衡/DNS/高级配置菜单与页面; 基础配置(出站+路由)完好
- 总览: 摘除 Xray更新弹窗(VersionModal+GeodataSection)全部入口, 版本号改为静态展示
- 13个locale同步摘除461个已删功能专属翻译键(逐键核查前后端引用为零; i18n-dead-keys测试全绿)

Xray更新后端(经调查确认仅服务本机bin/xray, 与agent下发/订阅无关):
- 删除 getXrayVersion/installXray/updateGeofile 路由与handler(访问返回404)
- 删除 ServerService 对应实现与校验和函数; 同步api-docs/monitor白名单/openapi

本机核心监管下线(设计: 主控永不运行本机xray, 入站全在agent):
- XrayService.RestartXray 降级为no-op(一次性Warning说明日志); StopXray无进程时幂等返回
- web.go 摘除启动拉起本机核心/CheckXrayRunning看门狗(1s)/ApplyPendingRestart cron(30s)/XrayTrafficJob/CheckClientIpJob 注册
- ApplyPendingRestart 降级为消费即清, 其测试更新为新语义
- XrayMetricsService.Sample 短路: 不再每2s探本机/debug/vars, 状态优雅置为不可用
- 配置生成(纯JSON)/agent链路/订阅/入站CRUD未动; 状态读数优雅归零(stop/Unknown)

注: TestGetXrayConfig_EnabledClientsStillEmitted 在基线945b3a5同样失败(既有问题, 非本轮引入)
This commit is contained in:
Muse
2026-10-20 23:39:38 +08:00
parent 945b3a56a2
commit a654a96c38
56 changed files with 149 additions and 11458 deletions
-160
View File
@@ -5042,44 +5042,6 @@
}
}
},
"/panel/api/server/getXrayVersion": {
"get": {
"tags": [
"Server"
],
"summary": "List Xray binary versions available for install on this host.",
"operationId": "get_panel_api_server_getXrayVersion",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
},
"example": {
"success": true,
"obj": [
"v25.10.31",
"v25.9.15",
"v25.8.1"
]
}
}
}
}
}
}
},
"/panel/api/server/getPanelUpdateInfo": {
"get": {
"tags": [
@@ -5613,47 +5575,6 @@
}
}
},
"/panel/api/server/installXray/{version}": {
"post": {
"tags": [
"Server"
],
"summary": "Download and install the specified Xray version. Pass \"latest\" for the newest release.",
"operationId": "post_panel_api_server_installXray_version",
"parameters": [
{
"name": "version",
"in": "path",
"required": true,
"description": "Xray tag (e.g. v25.10.31) or \"latest\".",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/server/updatePanel": {
"post": {
"tags": [
@@ -5730,87 +5651,6 @@
}
}
},
"/panel/api/server/updateGeofile": {
"post": {
"tags": [
"Server"
],
"summary": "Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.",
"operationId": "post_panel_api_server_updateGeofile",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/server/updateGeofile/{fileName}": {
"post": {
"tags": [
"Server"
],
"summary": "Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).",
"operationId": "post_panel_api_server_updateGeofile_fileName",
"parameters": [
{
"name": "fileName",
"in": "path",
"required": true,
"description": "Filename of the data file to refresh.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {}
}
}
}
}
}
}
}
},
"/panel/api/server/logs/{count}": {
"post": {
"tags": [
+1 -15
View File
@@ -12,16 +12,13 @@ import {
ClusterOutlined,
CodeOutlined,
DashboardOutlined,
DatabaseOutlined,
ExportOutlined,
GithubOutlined,
GlobalOutlined,
HeartOutlined,
ImportOutlined,
LogoutOutlined,
MailOutlined,
MenuOutlined,
MessageOutlined,
MoonFilled,
MoonOutlined,
PushpinFilled,
@@ -245,12 +242,6 @@ export default function AppSidebar() {
icon: <SafetyOutlined />,
label: t('pages.settings.securitySettings'),
},
{
key: '/settings#telegram',
icon: <MessageOutlined />,
label: t('pages.settings.TGBotSettings'),
},
{ key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
{
key: '/settings#subscription',
icon: <CloudServerOutlined />,
@@ -275,12 +266,7 @@ export default function AppSidebar() {
}, [t, showSubFormats, showSubBalancers]);
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
() => [
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
],
() => [{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') }],
[t],
);
-48
View File
@@ -396,12 +396,6 @@ export const sections: readonly Section[] = [
},
],
},
{
method: 'GET',
path: '/panel/api/server/getXrayVersion',
summary: 'List Xray binary versions available for install on this host.',
response: '{\n "success": true,\n "obj": ["v25.10.31", "v25.9.15", "v25.8.1"]\n}',
},
{
method: 'GET',
path: '/panel/api/server/getPanelUpdateInfo',
@@ -499,20 +493,6 @@ export const sections: readonly Section[] = [
'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
errorResponse: '{\n "success": false,\n "msg": "Xray config is invalid: ..."\n}',
},
{
method: 'POST',
path: '/panel/api/server/installXray/:version',
summary:
'Download and install the specified Xray version. Pass "latest" for the newest release.',
params: [
{
name: 'version',
in: 'path',
type: 'string',
desc: 'Xray tag (e.g. v25.10.31) or "latest".',
},
],
},
{
method: 'POST',
path: '/panel/api/server/updatePanel',
@@ -534,34 +514,6 @@ export const sections: readonly Section[] = [
],
body: 'dev=true',
},
{
method: 'POST',
path: '/panel/api/server/updateGeofile',
summary:
'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
params: [
{
name: 'fileName',
in: 'body (form)',
type: 'string',
desc: 'Filename to update (e.g. geoip.dat, geosite.dat). Omit to update all defaults.',
},
],
body: 'fileName=geoip.dat',
},
{
method: 'POST',
path: '/panel/api/server/updateGeofile/:fileName',
summary: 'Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).',
params: [
{
name: 'fileName',
in: 'path',
type: 'string',
desc: 'Filename of the data file to refresh.',
},
],
},
{
method: 'POST',
path: '/panel/api/server/logs/:count',
-228
View File
@@ -1,228 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
interface GeodataAssetRow {
url: string;
file: string;
}
interface GeodataSectionProps {
active: boolean;
onBusy: (e: { busy: boolean; tip?: string }) => void;
onClose: () => void;
}
const DEFAULT_CRON = '0 4 * * *';
// Xray resolves `file` inside its asset directory; plain file names only.
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
function fileNameFromUrl(url: string): string {
try {
const seg = new URL(url).pathname.split('/').filter(Boolean).pop() || '';
return FILE_NAME_PATTERN.test(seg) ? seg : '';
} catch {
return '';
}
}
export default function GeodataSection({ active, onBusy, onClose }: GeodataSectionProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [cron, setCron] = useState(DEFAULT_CRON);
const [outbound, setOutbound] = useState<string | undefined>(undefined);
const [rows, setRows] = useState<GeodataAssetRow[]>([]);
const [outboundTags, setOutboundTags] = useState<string[]>([]);
const [template, setTemplate] = useState<Record<string, unknown> | null>(null);
const outboundTestUrlRef = useRef('');
const load = useCallback(async () => {
try {
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success || typeof msg.obj !== 'string') return;
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
const next = (payload.xraySetting || {}) as Record<string, unknown>;
setTemplate(next);
outboundTestUrlRef.current =
typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : '';
const geodata = (next.geodata || {}) as Record<string, unknown>;
const assets = Array.isArray(geodata.assets) ? geodata.assets : [];
setRows(
assets
.filter((a): a is Record<string, unknown> => !!a && typeof a === 'object')
.map((a) => ({ url: String(a.url ?? ''), file: String(a.file ?? '') })),
);
setCron(typeof geodata.cron === 'string' && geodata.cron ? geodata.cron : DEFAULT_CRON);
setOutbound(
typeof geodata.outbound === 'string' && geodata.outbound ? geodata.outbound : undefined,
);
// Download outbound candidates: template outbounds + subscription outbounds.
// Skip blackhole outbounds — routing a download through one just drops it.
const tags = new Set<string>();
const outbounds = Array.isArray(next.outbounds) ? next.outbounds : [];
for (const o of outbounds) {
if (!o || typeof o !== 'object') continue;
const rec = o as Record<string, unknown>;
if (rec.protocol === 'blackhole') continue;
const tag = rec.tag;
if (typeof tag === 'string' && tag) tags.add(tag);
}
const subTags = Array.isArray(payload.subscriptionOutboundTags)
? payload.subscriptionOutboundTags
: [];
for (const tag of subTags) {
if (typeof tag === 'string' && tag) tags.add(tag);
}
setOutboundTags([...tags]);
} finally {
setLoading(false);
}
}, []);
const [wasActive, setWasActive] = useState(false);
if (active !== wasActive) {
setWasActive(active);
if (active) setLoading(true);
}
useEffect(() => {
if (active) void load();
}, [active, load]);
function setRow(index: number, patch: Partial<GeodataAssetRow>) {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
}
function onUrlBlur(index: number) {
setRows((prev) =>
prev.map((r, i) => (i === index && !r.file ? { ...r, file: fileNameFromUrl(r.url) } : r)),
);
}
function save() {
if (!template) return;
const assets = rows
.map((r) => ({ url: r.url.trim(), file: r.file.trim() }))
.filter((r) => r.url || r.file);
for (const a of assets) {
// Xray's geodata downloader accepts HTTPS URLs only.
if (!/^https:\/\/\S+$/i.test(a.url)) {
messageApi.error(t('pages.index.geodataInvalidUrl'));
return;
}
if (!FILE_NAME_PATTERN.test(a.file)) {
messageApi.error(t('pages.index.geodataInvalidFile'));
return;
}
}
const cronValue = cron.trim();
if (assets.length > 0 && cronValue && cronValue.split(/\s+/).length !== 5) {
messageApi.error(t('pages.index.geodataInvalidCron'));
return;
}
modal.confirm({
title: t('pages.index.geodataConfirmTitle'),
content: t('pages.index.geodataConfirmContent'),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
const next: Record<string, unknown> = { ...template };
if (assets.length === 0) {
delete next.geodata;
} else {
const geodata: Record<string, unknown> = { assets };
if (cronValue) geodata.cron = cronValue;
if (outbound) geodata.outbound = outbound;
next.geodata = geodata;
}
onClose();
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
try {
const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: JSON.stringify(next, null, 2),
outboundTestUrl: outboundTestUrlRef.current,
});
if (msg?.success) {
await HttpUtil.post('/panel/api/server/restartXrayService');
}
} finally {
onBusy({ busy: false });
}
},
});
}
return (
<div>
{modalContextHolder}
{messageContextHolder}
<Spin spinning={loading}>
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
<Form layout="vertical">
<Form.Item label={t('pages.index.geodataCron')} style={{ marginBottom: 8 }}>
<Input
value={cron}
placeholder={DEFAULT_CRON}
onChange={(e) => setCron(e.target.value)}
/>
</Form.Item>
<Form.Item label={t('pages.index.geodataOutbound')} style={{ marginBottom: 8 }}>
<Select
style={{ width: '100%' }}
allowClear
value={outbound}
onChange={(v) => setOutbound(v)}
options={outboundTags.map((tag) => ({ label: tag, value: tag }))}
/>
</Form.Item>
</Form>
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
{rows.length === 0 && (
<Typography.Text type="secondary">{t('pages.index.geodataEmpty')}</Typography.Text>
)}
{rows.map((row, i) => (
<Space.Compact key={i} style={{ width: '100%' }}>
<Input
style={{ width: '60%' }}
placeholder="https://example.com/geosite_custom.dat"
value={row.url}
onChange={(e) => setRow(i, { url: e.target.value })}
onBlur={() => onUrlBlur(i)}
/>
<Input
style={{ width: '40%' }}
placeholder={t('pages.index.geodataFile')}
value={row.file}
onChange={(e) => setRow(i, { file: e.target.value })}
/>
<Button
aria-label={t('delete')}
icon={<DeleteOutlined />}
onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
/>
</Space.Compact>
))}
<div className="actions-row">
<Button
icon={<PlusOutlined />}
onClick={() => setRows((p) => [...p, { url: '', file: '' }])}
>
{t('pages.index.geodataAddFile')}
</Button>
<Button type="primary" onClick={save} disabled={loading || !template}>
{t('pages.index.geodataSaveRestart')}
</Button>
</div>
</Space>
</Spin>
</div>
);
}
-12
View File
@@ -38,7 +38,6 @@ const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
const XrayLogModal = lazy(() => import('./XrayLogModal'));
const AmneziaWGLogModal = lazy(() => import('./AmneziaWGLogModal'));
const VersionModal = lazy(() => import('./VersionModal'));
import './IndexPage.css';
export default function IndexPage() {
@@ -69,7 +68,6 @@ export default function IndexPage() {
const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false);
const [xrayLogsOpen, setXrayLogsOpen] = useState(false);
const [amneziawgLogsOpen, setAmneziawgLogsOpen] = useState(false);
const [versionOpen, setVersionOpen] = useState(false);
const [configTextOpen, setConfigTextOpen] = useState(false);
const [configText, setConfigText] = useState('');
const [loading, setLoading] = useState(false);
@@ -210,7 +208,6 @@ export default function IndexPage() {
onOpenSystemHistory={() => setSysHistoryOpen(true)}
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
{health && (
@@ -334,15 +331,6 @@ export default function IndexPage() {
<LazyMount when={amneziawgLogsOpen}>
<AmneziaWGLogModal open={amneziawgLogsOpen} onClose={() => setAmneziawgLogsOpen(false)} />
</LazyMount>
<LazyMount when={versionOpen}>
<VersionModal
open={versionOpen}
status={status}
onClose={() => setVersionOpen(false)}
onBusy={setBusy}
/>
</LazyMount>
<LazyMount when={configTextOpen}>
<Modal
open={configTextOpen}
@@ -35,7 +35,6 @@ interface OverviewActionBarProps {
onOpenSystemHistory: () => void;
onOpenXrayMetrics: () => void;
onOpenPanelUpdate: () => void;
onOpenVersionSwitch: () => void;
}
interface BarAction {
@@ -69,7 +68,6 @@ export default function OverviewActionBar({
onOpenSystemHistory,
onOpenXrayMetrics,
onOpenPanelUpdate,
onOpenVersionSwitch,
}: OverviewActionBarProps) {
const { t } = useTranslation();
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
@@ -147,13 +145,7 @@ export default function OverviewActionBar({
<span className="ov-state" data-state={status.xray.state}>
<span className="ov-state-dot" style={{ color: status.xray.color }} />
<span>{`${t('pages.index.xrayStatus')} · ${stateText}`}</span>
{hasVersion && (
<Tooltip title={t('pages.index.xraySwitch')}>
<button type="button" className="ov-state-version" onClick={onOpenVersionSwitch}>
{`v${status.xray.version}`}
</button>
</Tooltip>
)}
{hasVersion && <span className="ov-state-version">{`v${status.xray.version}`}</span>}
</span>
);
-30
View File
@@ -1,30 +0,0 @@
.version-list {
width: 100%;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
overflow: hidden;
}
.version-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.version-list-item:last-child {
border-bottom: 0;
}
.reload-icon {
cursor: pointer;
font-size: 16px;
margin-right: 8px;
}
.actions-row {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
-179
View File
@@ -1,179 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Modal, Radio, Spin, Tag, Tooltip } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import type { Status } from '@/models/status';
import GeodataSection from './GeodataSection';
import './VersionModal.css';
interface BusyEvent {
busy: boolean;
tip?: string;
}
interface VersionModalProps {
open: boolean;
status: Status;
onClose: () => void;
onBusy: (e: BusyEvent) => void;
}
const GEOFILES = [
'geosite.dat',
'geoip.dat',
'geosite_IR.dat',
'geoip_IR.dat',
'geosite_RU.dat',
'geoip_RU.dat',
];
export default function VersionModal({ open, status, onClose, onBusy }: VersionModalProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [activeKey, setActiveKey] = useState<string | string[]>('1');
const [versions, setVersions] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const fetchVersions = useCallback(async () => {
try {
const msg = await HttpUtil.get<string[]>('/panel/api/server/getXrayVersion');
if (msg?.success) setVersions(msg.obj || []);
} finally {
setLoading(false);
}
}, []);
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setLoading(true);
}
useEffect(() => {
if (open) void fetchVersions();
}, [open, fetchVersions]);
function switchXrayVersion(version: string) {
modal.confirm({
title: t('pages.index.xraySwitchVersionDialog'),
content: t('pages.index.xraySwitchVersionDialogDesc').replace('#version#', version),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
onClose();
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
try {
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
} finally {
onBusy({ busy: false });
}
},
});
}
function updateGeofile(fileName: string) {
const isSingle = !!fileName;
modal.confirm({
title: t('pages.index.geofileUpdateDialog'),
content: isSingle
? t('pages.index.geofileUpdateDialogDesc').replace('#filename#', fileName)
: t('pages.index.geofilesUpdateDialogDesc'),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
onClose();
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
const url = isSingle
? `/panel/api/server/updateGeofile/${fileName}`
: '/panel/api/server/updateGeofile';
try {
await HttpUtil.post(url);
} finally {
onBusy({ busy: false });
}
},
});
}
const activeKeyStr = Array.isArray(activeKey) ? activeKey[0] : activeKey;
return (
<Modal open={open} title={t('pages.index.xrayUpdates')} footer={null} onCancel={onClose}>
{modalContextHolder}
<Spin spinning={loading}>
<Collapse
accordion
activeKey={activeKey}
onChange={setActiveKey}
items={[
{
key: '1',
label: 'Xray',
children: (
<>
<Alert
type="warning"
className="mb-12"
title={t('pages.index.xraySwitchClickDesk')}
showIcon
/>
<div className="version-list">
{versions.map((version, index) => (
<div key={version} className="version-list-item">
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{version}</Tag>
<Radio
checked={version === `v${status?.xray?.version}`}
onClick={() => switchXrayVersion(version)}
/>
</div>
))}
</div>
</>
),
},
{
key: '2',
label: 'Geofiles',
children: (
<>
<div className="version-list">
{GEOFILES.map((file, index) => (
<div key={file} className="version-list-item">
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{file}</Tag>
<Tooltip title={t('update')}>
<ReloadOutlined
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('update')}
onClick={() => updateGeofile(file)}
onKeyDown={activateOnKey(() => updateGeofile(file))}
/>
</Tooltip>
</div>
))}
</div>
<div className="actions-row">
<Button onClick={() => updateGeofile('')}>
{t('pages.index.geofilesUpdateAll')}
</Button>
</div>
</>
),
},
{
key: '3',
label: t('pages.index.geodataTitle'),
children: (
<GeodataSection active={activeKeyStr === '3'} onBusy={onBusy} onClose={onClose} />
),
},
]}
/>
</Spin>
</Modal>
);
}
-232
View File
@@ -1,232 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import type { AllSetting } from '@/models/setting';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import SecretInput from './SecretInput';
interface EmailTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
interface SmtpTestResult {
success: boolean;
stage?: string;
msg: string;
}
export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<SmtpTestResult | null>(null);
const stageLabel: Record<string, string> = {
connect: t('pages.settings.smtpStageConnect'),
auth: t('pages.settings.smtpStageAuth'),
send: t('pages.settings.smtpStageSend'),
};
async function handleTestSmtp() {
setTestLoading(true);
setTestResult(null);
try {
const res = (await HttpUtil.post('/panel/api/setting/testSmtp')) as SmtpTestResult;
setTestResult(res);
} catch (e: unknown) {
setTestResult({
success: false,
msg: e instanceof Error ? e.message : t('pages.settings.requestFailed'),
});
} finally {
setTestLoading(false);
}
}
return (
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.smtpSettings'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEnable')}
description={t('pages.settings.smtpEnableDesc')}
>
<Switch
checked={allSetting.smtpEnable}
onChange={(v) => updateSetting({ smtpEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpHost')}
description={t('pages.settings.smtpHostDesc')}
>
<Input
value={allSetting.smtpHost}
placeholder="smtp.gmail.com"
onChange={(e) => updateSetting({ smtpHost: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpPort')}
badge={<DefaultSettingTag settingKey="smtpPort" value={allSetting.smtpPort} />}
description={t('pages.settings.smtpPortDesc')}
>
<InputNumber
value={allSetting.smtpPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ smtpPort: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpUsername')}
description={t('pages.settings.smtpUsernameDesc')}
>
<Input
value={allSetting.smtpUsername}
placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpUsername: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpPassword')}
description={
allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword
? t('pages.settings.smtpPasswordConfigured')
: t('pages.settings.smtpPasswordDesc')
}
>
<SecretInput
value={allSetting.smtpPassword}
configured={allSetting.hasSmtpPassword}
clearArmed={allSetting.clearSmtpPassword}
placeholder={t('pages.settings.smtpPasswordPlaceholder')}
onChange={(v) => updateSetting({ smtpPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpFrom')}
description={t('pages.settings.smtpFromDesc')}
>
<Input
value={allSetting.smtpFrom}
placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpFrom: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpFromName')}
description={t('pages.settings.smtpFromNameDesc')}
>
<Input
value={allSetting.smtpFromName}
placeholder="3x-ui"
onChange={(e) => updateSetting({ smtpFromName: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpTo')}
description={t('pages.settings.smtpToDesc')}
>
<Input
value={allSetting.smtpTo}
placeholder="admin@example.com, ops@example.com"
onChange={(e) => updateSetting({ smtpTo: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEncryption')}
description={t('pages.settings.smtpEncryptionDesc')}
>
<Select
value={allSetting.smtpEncryptionType}
onChange={(v) => updateSetting({ smtpEncryptionType: v })}
options={[
{ value: 'none', label: t('pages.settings.smtpEncryptionNone') },
{ value: 'starttls', label: t('pages.settings.smtpEncryptionStartTLS') },
{ value: 'tls', label: t('pages.settings.smtpEncryptionTLS') },
]}
style={{ width: '100%' }}
/>
</SettingListItem>
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button
type="primary"
icon={<SendOutlined />}
loading={testLoading}
onClick={handleTestSmtp}
>
{t('pages.settings.testSmtp')}
</Button>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
title={
testResult.success ? (
t('pages.settings.' + testResult.msg)
) : (
<span>
<b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b>{' '}
{t('pages.settings.' + testResult.msg)}
</span>
)
}
showIcon
closable={{ onClose: () => setTestResult(null) }}
/>
)}
</Space>
</>
),
},
{
key: '2',
label: catTabLabel(<MailOutlined />, t('pages.settings.emailNotifications'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEventBusNotify')}
description={t('pages.settings.smtpEventBusNotifyDesc')}
>
<EmailNotifications allSetting={allSetting} updateSetting={updateSetting} />
</SettingListItem>
</>
),
},
]}
/>
);
}
+1 -338
View File
@@ -1,14 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
import {
ApartmentOutlined,
BellOutlined,
ClockCircleOutlined,
GlobalOutlined,
SafetyCertificateOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { ClockCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { HttpUtil, LanguageManager } from '@/utils';
import { onNumber } from '@/utils/onNumber';
@@ -16,7 +9,6 @@ import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath } from './uriPath';
import SecretInput from './SecretInput';
interface ApiMsg<T = unknown> {
success?: boolean;
@@ -33,39 +25,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
const { isMobile } = useMediaQuery();
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
const [outboundTagList, setOutboundTagList] = useState<string[]>([]);
const [balancerTagList, setBalancerTagList] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
// /options is the slim picker-shaped endpoint — it skips the heavy
// per-client settings and clientStats payloads that /list ships.
const msg = (await HttpUtil.get('/panel/api/inbounds/options')) as ApiMsg<
{
tag: string;
protocol: string;
port: number;
}[]
>;
if (cancelled) return;
if (msg?.success && Array.isArray(msg.obj)) {
setInboundOptions(
msg.obj.map((ib) => ({
label: `${ib.tag} (${ib.protocol}@${ib.port})`,
value: ib.tag,
})),
);
} else {
setInboundOptions([]);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
@@ -136,20 +98,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
];
}, [outboundTagList, balancerTagList, t]);
const ldapInboundTagList = useMemo(() => {
const csv = allSetting.ldapInboundTags || '';
return csv.length
? csv
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
}, [allSetting.ldapInboundTags]);
function setLdapInboundTagList(list: string[]) {
updateSetting({ ldapInboundTags: Array.isArray(list) ? list.join(',') : '' });
}
function onLangChange(value: string) {
setLang(value);
LanguageManager.setLanguage(value);
@@ -327,43 +275,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
</>
),
},
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.expireTimeDiff')}
badge={<DefaultSettingTag settingKey="expireDiff" value={allSetting.expireDiff} />}
description={t('pages.settings.expireTimeDiffDesc')}
>
<InputNumber
value={allSetting.expireDiff}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ expireDiff: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficDiff')}
badge={
<DefaultSettingTag settingKey="trafficDiff" value={allSetting.trafficDiff} />
}
description={t('pages.settings.trafficDiffDesc')}
>
<InputNumber
value={allSetting.trafficDiff}
min={0}
max={100}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))}
/>
</SettingListItem>
</>
),
},
{
key: '3',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
@@ -392,35 +303,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
</>
),
},
{
key: '4',
label: catTabLabel(<GlobalOutlined />, t('pages.settings.externalTraffic'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.externalTrafficInformEnable')}
description={t('pages.settings.externalTrafficInformEnableDesc')}
>
<Switch
checked={allSetting.externalTrafficInformEnable}
onChange={(v) => updateSetting({ externalTrafficInformEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.externalTrafficInformURI')}
description={t('pages.settings.externalTrafficInformURIDesc')}
>
<Input
value={allSetting.externalTrafficInformURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '5',
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile),
@@ -454,225 +336,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
</>
),
},
{
key: '6',
label: catTabLabel(<ApartmentOutlined />, 'LDAP', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.ldap.enable')}>
<Switch
checked={allSetting.ldapEnable}
onChange={(v) => updateSetting({ ldapEnable: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
<Input
value={allSetting.ldapHost}
onChange={(e) => updateSetting({ ldapHost: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.port')}
badge={<DefaultSettingTag settingKey="ldapPort" value={allSetting.ldapPort} />}
>
<InputNumber
value={allSetting.ldapPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapPort: v }))}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
<Switch
checked={allSetting.ldapUseTLS}
onChange={(v) => updateSetting({ ldapUseTLS: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.skipTlsVerify')}
description={t('pages.settings.ldap.skipTlsVerifyDesc')}
>
<Switch
checked={allSetting.ldapInsecureSkipVerify}
disabled={!allSetting.ldapUseTLS}
onChange={(v) => updateSetting({ ldapInsecureSkipVerify: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.bindDn')}>
<Input
value={allSetting.ldapBindDN}
onChange={(e) => updateSetting({ ldapBindDN: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('password')}
description={
allSetting.hasLdapPassword && !allSetting.clearLdapPassword
? t('pages.settings.ldap.passwordConfigured')
: t('pages.settings.ldap.passwordUnconfigured')
}
>
<SecretInput
value={allSetting.ldapPassword}
configured={allSetting.hasLdapPassword}
clearArmed={allSetting.clearLdapPassword}
placeholder={t('pages.settings.ldap.passwordPlaceholder')}
onChange={(v) => updateSetting({ ldapPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}>
<Input
value={allSetting.ldapBaseDN}
onChange={(e) => updateSetting({ ldapBaseDN: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.userFilter')}>
<Input
value={allSetting.ldapUserFilter}
onChange={(e) => updateSetting({ ldapUserFilter: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.userAttr')}>
<Input
value={allSetting.ldapUserAttr}
onChange={(e) => updateSetting({ ldapUserAttr: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.vlessField')}>
<Input
value={allSetting.ldapVlessField}
onChange={(e) => updateSetting({ ldapVlessField: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.flagField')}
description={t('pages.settings.ldap.flagFieldDesc')}
>
<Input
value={allSetting.ldapFlagField}
onChange={(e) => updateSetting({ ldapFlagField: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.truthyValues')}
description={t('pages.settings.ldap.truthyValuesDesc')}
>
<Input
value={allSetting.ldapTruthyValues}
onChange={(e) => updateSetting({ ldapTruthyValues: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.invertFlag')}
description={t('pages.settings.ldap.invertFlagDesc')}
>
<Switch
checked={allSetting.ldapInvertFlag}
onChange={(v) => updateSetting({ ldapInvertFlag: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.syncSchedule')}
description={t('pages.settings.ldap.syncScheduleDesc')}
>
<Input
value={allSetting.ldapSyncCron}
onChange={(e) => updateSetting({ ldapSyncCron: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.inboundTags')}
description={t('pages.settings.ldap.inboundTagsDesc')}
>
<>
<Select
mode="multiple"
value={ldapInboundTagList}
onChange={setLdapInboundTagList}
style={{ width: '100%' }}
options={inboundOptions}
/>
{inboundOptions.length === 0 && (
<div className="ldap-no-inbounds">{t('pages.settings.ldap.noInbounds')}</div>
)}
</>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoCreate')}>
<Switch
checked={allSetting.ldapAutoCreate}
onChange={(v) => updateSetting({ ldapAutoCreate: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
<Switch
checked={allSetting.ldapAutoDelete}
onChange={(v) => updateSetting({ ldapAutoDelete: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultTotalGb')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultTotalGB"
value={allSetting.ldapDefaultTotalGB}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultTotalGB}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultExpiryDays')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultExpiryDays"
value={allSetting.ldapDefaultExpiryDays}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultExpiryDays}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultIpLimit')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultLimitIP"
value={allSetting.ldapDefaultLimitIP}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultLimitIP}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))}
/>
</SettingListItem>
</>
),
},
]}
/>
);
@@ -1,45 +0,0 @@
import { Button, Input, Space } from 'antd';
import { useTranslation } from 'react-i18next';
interface SecretInputProps {
value: string;
configured: boolean;
clearArmed: boolean;
placeholder: string;
onChange: (value: string) => void;
onClearArmedChange: (armed: boolean) => void;
}
export default function SecretInput({
value,
configured,
clearArmed,
placeholder,
onChange,
onClearArmedChange,
}: SecretInputProps) {
const { t } = useTranslation();
return (
<Space.Compact style={{ width: '100%' }}>
<Input.Password
value={value}
placeholder={configured && !clearArmed ? placeholder : ''}
onChange={(e) => {
onChange(e.target.value);
if (clearArmed) onClearArmedChange(false);
}}
/>
{configured && (
<Button
danger={clearArmed}
onClick={() => {
onChange('');
onClearArmedChange(!clearArmed);
}}
>
{clearArmed ? t('pages.settings.secretClearUndo') : t('pages.settings.secretClear')}
</Button>
)}
</Space.Compact>
);
}
+4 -124
View File
@@ -1,13 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Empty, Form, Input, Modal, Space, Spin, Switch, Tabs, message } from 'antd';
import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil, RandomUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
import { ApiOutlined, UserOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil } from '@/utils';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import TwoFactorModal from './TwoFactorModal';
import './SecurityTab.css';
interface ApiMsg<T = unknown> {
@@ -25,45 +23,18 @@ interface ApiTokenRow {
expiresAt: number;
}
interface SecurityTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
saveSetting: (payload: Partial<AllSetting> & Record<string, unknown>) => Promise<unknown>;
}
const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000;
function apiTokenCreatedAtMilliseconds(createdAt: number): number {
return createdAt < UNIX_MILLISECONDS_THRESHOLD ? createdAt * 1000 : createdAt;
}
type TfaType = 'set' | 'confirm';
interface TfaState {
open: boolean;
title: string;
description: string;
token: string;
type: TfaType;
onConfirm: (success: boolean, code?: string) => void;
}
const TFA_INITIAL: TfaState = {
open: false,
title: '',
description: '',
token: '',
type: 'set',
onConfirm: () => {},
};
export default function SecurityTab({ allSetting, updateSetting, saveSetting }: SecurityTabProps) {
export default function SecurityTab() {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [tfa, setTfa] = useState<TfaState>(TFA_INITIAL);
const [user, setUser] = useState({
oldUsername: '',
oldPassword: '',
@@ -79,17 +50,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
const [creating, setCreating] = useState(false);
const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null);
const openTfa = useCallback((opts: Omit<TfaState, 'open'>) => {
setTfa({ ...opts, open: true });
}, []);
const onTfaConfirm = useCallback(
(success: boolean, code?: string) => {
tfa.onConfirm(success, code);
},
[tfa],
);
function updateUserField<K extends keyof typeof user>(key: K, value: string) {
setUser((prev) => ({ ...prev, [key]: value }));
}
@@ -115,19 +75,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
);
function onUpdateUserClick() {
if (allSetting.twoFactorEnable) {
openTfa({
title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
token: '',
type: 'confirm',
onConfirm: (ok: boolean, code?: string) => {
if (ok) sendUpdateUser(code || '');
},
});
} else {
sendUpdateUser();
}
sendUpdateUser();
}
const fetchApiTokens = useCallback(async () => {
@@ -217,47 +165,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts));
}
function toggleTwoFactor() {
if (!allSetting.twoFactorEnable) {
const newToken = RandomUtil.randomBase32String();
openTfa({
title: t('pages.settings.security.twoFactorModalSetTitle'),
description: '',
token: newToken,
type: 'set',
onConfirm: (ok: boolean) => {
if (ok) {
messageApi.success(t('pages.settings.security.twoFactorModalSetSuccess'));
updateSetting({ twoFactorToken: newToken, twoFactorEnable: true });
} else {
updateSetting({ twoFactorEnable: false });
}
},
});
} else {
openTfa({
title: t('pages.settings.security.twoFactorModalDeleteTitle'),
description: t('pages.settings.security.twoFactorModalRemoveStep'),
token: '',
type: 'confirm',
onConfirm: async (ok: boolean, code?: string) => {
if (!ok) return;
const next = {
...allSetting,
twoFactorEnable: false,
twoFactorToken: '',
twoFactorCode: code || '',
};
const msg = (await saveSetting(next)) as ApiMsg;
if (msg?.success) {
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
updateSetting({ twoFactorEnable: false, twoFactorToken: '', hasTwoFactorToken: false });
}
},
});
}
}
return (
<>
{messageContextHolder}
@@ -307,23 +214,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
</>
),
},
{
key: '2',
label: catTabLabel(
<SafetyOutlined />,
t('pages.settings.security.twoFactor'),
isMobile,
),
children: (
<SettingListItem
paddings="small"
title={t('pages.settings.security.twoFactorEnable')}
description={t('pages.settings.security.twoFactorEnableDesc')}
>
<Switch checked={allSetting.twoFactorEnable} onClick={toggleTwoFactor} />
</SettingListItem>
),
},
{
key: '3',
label: catTabLabel(<ApiOutlined />, t('pages.nodes.apiToken'), isMobile),
@@ -422,16 +312,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
</Button>
</div>
</Modal>
<TwoFactorModal
open={tfa.open}
title={tfa.title}
description={tfa.description}
token={tfa.token}
type={tfa.type}
onConfirm={onTfaConfirm}
onOpenChange={(open) => setTfa((prev) => ({ ...prev, open }))}
/>
</>
);
}
+4 -26
View File
@@ -25,8 +25,6 @@ import { AllSettingSchema } from '@/schemas/setting';
import AppSidebar from '@/layouts/AppSidebar';
import GeneralTab from './GeneralTab';
import SecurityTab from './SecurityTab';
import TelegramTab from './TelegramTab';
import EmailTab from './EmailTab';
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
import SubscriptionBalancersTab from './SubscriptionBalancersTab';
@@ -39,8 +37,6 @@ interface ApiMsg {
const tabSlugs = [
'general',
'security',
'telegram',
'email',
'subscription',
'subscription-formats',
'subscription-balancers',
@@ -77,16 +73,8 @@ export default function SettingsPage() {
setMessageInstance(messageApi);
}, [messageApi]);
const {
allSetting,
updateSetting,
fetched,
spinning,
setSpinning,
saveDisabled,
saveAll,
savePayload,
} = useAllSettings();
const { allSetting, updateSetting, fetched, spinning, setSpinning, saveDisabled, saveAll } =
useAllSettings();
const [entryHost] = useState(() => window.location.hostname);
const [entryPort] = useState(() => window.location.port);
@@ -206,17 +194,7 @@ export default function SettingsPage() {
const categoryBody = useMemo(() => {
switch (activeSlug) {
case 'security':
return (
<SecurityTab
allSetting={allSetting}
updateSetting={updateSetting}
saveSetting={savePayload}
/>
);
case 'telegram':
return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'email':
return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
return <SecurityTab />;
case 'subscription':
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-formats':
@@ -226,7 +204,7 @@ export default function SettingsPage() {
default:
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
}
}, [activeSlug, allSetting, updateSetting, savePayload]);
}, [activeSlug, allSetting, updateSetting]);
return (
<ConfigProvider theme={antdThemeConfig}>
-337
View File
@@ -1,337 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { LanguageManager } from '@/utils';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import SecretInput from './SecretInput';
interface TelegramTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
// The notification schedule is fed straight to robfig/cron's AddJob (see
// web.go startTask), which accepts @every <duration>, the @hourly/@daily/...
// macros, and full crontab expressions. This builder covers the common cases
// with dropdowns so users don't have to memorise the syntax, while "Custom"
// preserves the raw crontab escape hatch.
type Unit = 's' | 'm' | 'h';
type Macro = '@hourly' | '@daily' | '@weekly' | '@monthly';
type Mode = 'every' | Macro | 'custom';
const MACROS: Macro[] = ['@hourly', '@daily', '@weekly', '@monthly'];
const EVERY_RE = /^@every\s+(\d+)\s*([smh])$/i;
interface RunTime {
mode: Mode;
num: number;
unit: Unit;
custom: string;
}
function parseRunTime(raw: string): RunTime {
const v = (raw ?? '').trim();
const m = v.match(EVERY_RE);
if (m) {
return {
mode: 'every',
num: Math.max(1, Number(m[1]) || 1),
unit: m[2].toLowerCase() as Unit,
custom: '',
};
}
if ((MACROS as string[]).includes(v)) {
return { mode: v as Macro, num: 1, unit: 'h', custom: '' };
}
return { mode: 'custom', num: 1, unit: 'h', custom: v };
}
function composeRunTime(s: RunTime): string {
if (s.mode === 'every') return `@every ${Math.max(1, s.num || 1)}${s.unit}`;
if (s.mode === 'custom') return s.custom;
return s.mode;
}
// The panel's cron runs with seconds enabled (cron.WithSeconds() in web.go), so
// crontab expressions are 6-field: "second minute hour day month weekday". When
// the user drops into Custom we seed the box with the crontab equivalent of the
// current selection rather than a bare @macro, so they get a real expression to
// edit (and one that the 6-field parser accepts).
function toCrontab(s: RunTime): string {
switch (s.mode) {
case '@hourly':
return '0 0 * * * *';
case '@daily':
return '0 0 0 * * *';
case '@weekly':
return '0 0 0 * * 0';
case '@monthly':
return '0 0 0 1 * *';
case 'every': {
const n = Math.max(1, s.num || 1);
if (s.unit === 's') return `*/${n} * * * * *`;
if (s.unit === 'm') return `0 */${n} * * * *`;
return `0 0 */${n} * * *`;
}
default:
return s.custom;
}
}
function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { t } = useTranslation();
// Init once: the Settings tabs only mount after settings are fetched, so the
// incoming value is already the persisted one.
const [state, setState] = useState<RunTime>(() => parseRunTime(value));
function update(patch: Partial<RunTime>) {
const next = { ...state, ...patch };
setState(next);
onChange(composeRunTime(next));
}
function onModeChange(mode: Mode) {
// Seed Custom with the crontab equivalent of the current selection so the
// box starts from a real expression (e.g. "0 0 0 * * *", not "@daily").
if (mode === 'custom' && !state.custom.trim()) {
update({ mode, custom: toCrontab(state) });
} else {
update({ mode });
}
}
const modeOptions = [
{ value: 'every', label: t('pages.settings.notifyTime.every') },
{ value: '@hourly', label: t('pages.settings.notifyTime.hourly') },
{ value: '@daily', label: t('pages.settings.notifyTime.daily') },
{ value: '@weekly', label: t('pages.settings.notifyTime.weekly') },
{ value: '@monthly', label: t('pages.settings.notifyTime.monthly') },
{ value: 'custom', label: t('pages.settings.notifyTime.custom') },
];
const unitOptions = [
{ value: 's', label: t('pages.settings.notifyTime.seconds') },
{ value: 'm', label: t('pages.settings.notifyTime.minutes') },
{ value: 'h', label: t('pages.settings.notifyTime.hours') },
];
return (
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Select<Mode>
style={{ width: '100%' }}
value={state.mode}
options={modeOptions}
onChange={onModeChange}
aria-label={t('pages.settings.telegramNotifyTime')}
/>
{state.mode === 'every' && (
<Space.Compact style={{ width: '100%' }}>
<InputNumber
min={1}
precision={0}
style={{ width: '50%' }}
value={state.num}
onChange={onNumber((v) => update({ num: Math.max(1, v) }))}
aria-label={t('pages.settings.notifyTime.interval')}
/>
<Select<Unit>
style={{ width: '50%' }}
value={state.unit}
options={unitOptions}
onChange={(unit) => update({ unit })}
aria-label={t('pages.settings.notifyTime.unit')}
/>
</Space.Compact>
)}
{state.mode === 'custom' && (
<Input
value={state.custom}
placeholder="0 30 8 * * *"
onChange={(e) => update({ custom: e.target.value })}
aria-label={t('pages.settings.notifyTime.custom')}
/>
)}
</Space>
);
}
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; msg: string } | null>(null);
async function handleTestTgBot() {
setTestLoading(true);
setTestResult(null);
try {
const res = (await HttpUtil.post('/panel/api/setting/testTgBot')) as {
success?: boolean;
msg?: string;
};
setTestResult({ success: !!res.success, msg: res.msg || '' });
} catch (e: unknown) {
setTestResult({
success: false,
msg: e instanceof Error ? e.message : t('pages.settings.requestFailed'),
});
} finally {
setTestLoading(false);
}
}
const langOptions = useMemo(
() =>
LanguageManager.supportedLanguages.map(
(l: { value: string; name: string; icon: string }) => ({
value: l.value,
label: (
<>
<span role="img" aria-label={l.name}>
{l.icon}
</span>
&nbsp;&nbsp;<span>{l.name}</span>
</>
),
}),
),
[],
);
return (
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramBotEnable')}
description={t('pages.settings.telegramBotEnableDesc')}
>
<Switch
checked={allSetting.tgBotEnable}
onChange={(v) => updateSetting({ tgBotEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramToken')}
description={
allSetting.hasTgBotToken && !allSetting.clearTgBotToken
? t('pages.settings.telegramTokenConfigured')
: t('pages.settings.telegramTokenDesc')
}
>
<SecretInput
value={allSetting.tgBotToken}
configured={allSetting.hasTgBotToken}
clearArmed={allSetting.clearTgBotToken}
placeholder={t('pages.settings.telegramTokenPlaceholder')}
onChange={(v) => updateSetting({ tgBotToken: v })}
onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramChatId')}
description={t('pages.settings.telegramChatIdDesc')}
>
<Input
value={allSetting.tgBotChatId}
onChange={(e) => updateSetting({ tgBotChatId: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.telegramBotLanguage')}>
<Select
value={allSetting.tgLang}
onChange={(v) => updateSetting({ tgLang: v })}
style={{ width: '100%' }}
options={langOptions}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramAPIServer')}
description={t('pages.settings.telegramAPIServerDesc')}
>
<Input
value={allSetting.tgBotAPIServer}
placeholder="https://api.example.com"
onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })}
/>
</SettingListItem>
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button
type="primary"
icon={<SendOutlined />}
loading={testLoading}
onClick={handleTestTgBot}
>
{t('pages.settings.testTgBot')}
</Button>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
title={testResult.msg}
showIcon
closable={{ onClose: () => setTestResult(null) }}
/>
)}
</Space>
</>
),
},
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.telegramNotifyTime')}
description={t('pages.settings.telegramNotifyTimeDesc')}
>
<NotifyTimeField
value={allSetting.tgRunTime}
onChange={(v) => updateSetting({ tgRunTime: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.tgNotifyBackup')}
description={t('pages.settings.tgNotifyBackupDesc')}
>
<Switch
checked={allSetting.tgBotBackup}
onChange={(v) => updateSetting({ tgBotBackup: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.tgEventBusNotify')}
description={t('pages.settings.tgEventBusNotifyDesc')}
>
<TelegramNotifications allSetting={allSetting} updateSetting={updateSetting} />
</SettingListItem>
</>
),
},
]}
/>
);
}
@@ -1,17 +0,0 @@
.qr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.qr-code {
cursor: pointer;
}
.qr-token {
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
text-align: center;
}
@@ -1,161 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
import * as OTPAuth from 'otpauth';
import { ClipboardManager } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { TotpCodeSchema } from '@/schemas/login';
import './TwoFactorModal.css';
type Type = 'set' | 'confirm';
interface TwoFactorModalProps {
open: boolean;
title?: string;
description?: string;
token?: string;
type?: Type;
onConfirm: (success: boolean, code?: string) => void;
onOpenChange: (open: boolean) => void;
}
export default function TwoFactorModal({
open,
title = '',
description = '',
token = '',
type = 'set',
onConfirm,
onOpenChange,
}: TwoFactorModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [enteredCode, setEnteredCode] = useState('');
const totp = useMemo(() => {
if (!open || !token) return null;
return new OTPAuth.TOTP({
issuer: '3x-ui',
label: 'Administrator',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: token,
});
}, [open, token]);
const qrValue = totp ? totp.toString() : '';
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setEnteredCode('');
}
function close(success: boolean, code = '') {
onConfirm(success, code);
onOpenChange(false);
setEnteredCode('');
}
function onOk() {
const codeOk = TotpCodeSchema.safeParse(enteredCode);
if (!codeOk.success) {
messageApi.error(
t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError'),
);
return;
}
if (type === 'confirm' && !token) {
close(true, codeOk.data);
return;
}
if (!totp) return;
if (totp.generate() === codeOk.data) {
close(true);
} else {
messageApi.error(t('pages.settings.security.twoFactorModalError'));
}
}
function onCancel() {
close(false);
}
async function copyToken() {
const ok = await ClipboardManager.copyText(token);
if (ok) messageApi.success(t('copied'));
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={title}
closable
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>
{t('cancel')}
</Button>,
<Button
key="ok"
type="primary"
disabled={!TotpCodeSchema.safeParse(enteredCode).success}
onClick={onOk}
>
{t('confirm')}
</Button>,
]}
>
{type === 'set' ? (
<>
<p>{t('pages.settings.security.twoFactorModalSteps')}</p>
<Divider />
<p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
<div
className="qr-wrap"
role="button"
tabIndex={0}
aria-label={t('copy')}
onClick={copyToken}
onKeyDown={activateOnKey(copyToken)}
>
<QRCode
className="qr-code"
value={qrValue}
size={180}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
errorLevel="L"
title={t('copy')}
/>
<span className="qr-token">{token}</span>
</div>
<Divider />
<p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
<Input
value={enteredCode}
onChange={(e) => setEnteredCode(e.target.value)}
style={{ width: '100%' }}
aria-label={t('twoFactorCode')}
/>
</>
) : (
<>
<p>{description}</p>
<Input
value={enteredCode}
onChange={(e) => setEnteredCode(e.target.value)}
style={{ width: '100%' }}
aria-label={t('twoFactorCode')}
/>
</>
)}
</Modal>
</>
);
}
+4 -101
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router';
import { useLocation } from 'react-router';
import {
Alert,
Button,
@@ -10,7 +10,6 @@ import {
FloatButton,
Layout,
message,
Radio,
Result,
Row,
Space,
@@ -22,26 +21,21 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useXraySetting } from '@/hooks/useXraySetting';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import AppSidebar from '@/layouts/AppSidebar';
import { JsonEditor } from '@/components/form';
import { setMessageInstance } from '@/utils/messageBus';
import { BasicsTab } from './basics';
import { propagateOutboundTagRename } from './basics/helpers';
import { RoutingTab } from './routing';
import { OutboundsTab } from './outbounds';
import { BalancersTab } from './balancers';
import {
cleanupOrphanedBalancerLoopbacks,
ensureMissingBalancerLoopbacks,
detectBalancerCycles,
} from './balancers/balancer-loopback';
import { DnsTab } from './dns';
import { WarpModal, NordModal, PiaModal } from './overrides';
import './XrayPage.css';
const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', 'advanced'];
type AdvKey = 'xraySetting' | 'inboundSettings' | 'outboundSettings' | 'routingRuleSettings';
const SECTION_SLUGS = ['basic', 'routing', 'outbound'];
export default function XrayPage() {
const { t } = useTranslation();
@@ -83,9 +77,7 @@ export default function XrayPage() {
const [warpOpen, setWarpOpen] = useState(false);
const [nordOpen, setNordOpen] = useState(false);
const [piaOpen, setPiaOpen] = useState(false);
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
const location = useLocation();
const navigate = useNavigate();
const pathSection =
location.pathname === '/outbound'
? 'outbound'
@@ -157,59 +149,11 @@ export default function XrayPage() {
});
}
const advancedText = useMemo(() => {
if (advSettings === 'xraySetting') return xraySetting;
const tpl = templateSettings;
if (!tpl) return '';
try {
switch (advSettings) {
case 'inboundSettings':
return JSON.stringify(tpl.inbounds || [], null, 2);
case 'outboundSettings':
return JSON.stringify(tpl.outbounds || [], null, 2);
case 'routingRuleSettings':
return JSON.stringify(tpl.routing?.rules || [], null, 2);
default:
return '';
}
} catch {
return '';
}
}, [advSettings, xraySetting, templateSettings]);
function onAdvancedTextChange(next: string) {
if (advSettings === 'xraySetting') {
setXraySetting(next);
return;
}
let parsed;
try {
parsed = JSON.parse(next);
} catch {
return;
}
mutate((tt) => {
switch (advSettings) {
case 'inboundSettings':
tt.inbounds = parsed;
break;
case 'outboundSettings':
tt.outbounds = parsed;
break;
case 'routingRuleSettings':
if (!tt.routing) tt.routing = {};
tt.routing.rules = parsed;
break;
}
});
}
function onSaveAll() {
try {
JSON.parse(xraySetting);
} catch (e) {
messageApi.error(`Advanced JSON: ${(e as Error).message}`);
navigate('/xray#advanced');
messageApi.error(`Template JSON: ${(e as Error).message}`);
return;
}
if (templateSettings) {
@@ -269,47 +213,6 @@ export default function XrayPage() {
onRefreshXrayData={fetchAll}
/>
);
case 'balancer':
return (
<BalancersTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
clientReverseTags={clientReverseTags}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
/>
);
case 'dns':
return (
<DnsTab templateSettings={templateSettings} setTemplateSettings={setTemplateSettings} />
);
case 'advanced':
return (
<>
<div className="advanced-meta">
<h4>{t('pages.xray.Template')}</h4>
<p>{t('pages.xray.TemplateDesc')}</p>
</div>
<Radio.Group
value={advSettings}
buttonStyle="solid"
size={isMobile ? 'small' : 'middle'}
style={{ margin: '12px 0' }}
onChange={(e) => setAdvSettings(e.target.value)}
>
<Radio.Button value="xraySetting">{t('pages.xray.completeTemplate')}</Radio.Button>
<Radio.Button value="inboundSettings">{t('pages.xray.Inbounds')}</Radio.Button>
<Radio.Button value="outboundSettings">{t('pages.xray.Outbounds')}</Radio.Button>
<Radio.Button value="routingRuleSettings">{t('pages.xray.Routings')}</Radio.Button>
</Radio.Group>
<JsonEditor
value={advancedText}
onChange={onAdvancedTextChange}
minHeight="420px"
maxHeight="720px"
/>
</>
);
default:
return (
<BasicsTab
@@ -1,425 +0,0 @@
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import type { Path } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { BalancerFormSchema, type BalancerFormValues } from '@/schemas/xray';
import {
BalancerStrategyTypeSchema,
type BalancerStrategyType,
type BalancerObject,
} from '@/schemas/routing';
import { isBalancerLoopbackTag } from './balancer-loopback';
export type BalancerFormValue = BalancerFormValues;
interface BalancerFormModalProps {
open: boolean;
balancer: BalancerFormValue | null;
outboundTags: string[];
balancerTags: string[];
balancers: BalancerObject[];
templateSettings: XraySettingsValue | null;
otherTags: string[];
onClose: () => void;
onConfirm: (value: BalancerFormValue) => void;
}
const STRATEGY_LABELS: Record<string, string> = {
random: 'Random',
roundRobin: 'Round robin',
leastLoad: 'Least load',
leastPing: 'Least ping',
};
const STRATEGIES = BalancerStrategyTypeSchema.options.map((value) => ({
value,
label: STRATEGY_LABELS[value] ?? value,
}));
function initialState(balancer: BalancerFormValue | null): BalancerFormValues {
if (!balancer) {
return { tag: '', strategy: 'random', selector: [], fallbackTag: '' };
}
return {
tag: balancer.tag ?? '',
strategy: (balancer.strategy ?? 'random') as BalancerStrategyType,
selector: [...(balancer.selector ?? [])],
fallbackTag: balancer.fallbackTag ?? '',
settings: balancer.settings,
};
}
export default function BalancerFormModal({
open,
balancer,
outboundTags,
balancerTags,
balancers,
templateSettings,
otherTags,
onClose,
onConfirm,
}: BalancerFormModalProps) {
const { t } = useTranslation();
const methods = useForm<BalancerFormValues>({ defaultValues: initialState(balancer) });
const [submitAttempted, setSubmitAttempted] = useState(false);
const isEdit = balancer != null;
const openBalancer = open ? (balancer ?? null) : undefined;
const [syncedBalancer, setSyncedBalancer] = useState<typeof openBalancer>(undefined);
if (openBalancer !== syncedBalancer) {
setSyncedBalancer(openBalancer);
if (open) {
methods.reset(initialState(balancer));
setSubmitAttempted(false);
}
}
const strategy = useWatch({ control: methods.control, name: 'strategy' });
const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? [];
const costs = useWatch({ control: methods.control, name: 'settings.costs' }) ?? [];
const tagValue = useWatch({ control: methods.control, name: 'tag' }) ?? '';
const fallbackTag = useWatch({ control: methods.control, name: 'fallbackTag' }) ?? '';
const currentTag = tagValue.trim();
const availableBalancerTags = useMemo(
() => balancerTags.filter((tg) => tg !== currentTag),
[balancerTags, currentTag],
);
const cycleInfo = useMemo(() => {
const rules = (templateSettings?.routing?.rules || []) as Array<{
inboundTag?: string[];
balancerTag?: string;
}>;
const resolveLoopback = (tag: string): string | null => {
for (const r of rules) {
if (Array.isArray(r.inboundTag) && r.inboundTag.includes(tag) && r.balancerTag) {
return r.balancerTag;
}
}
return null;
};
const fallbackOf: Record<string, string> = {};
for (const b of balancers) {
if (!b.tag || !b.fallbackTag || b.tag === currentTag) continue;
const target = isBalancerLoopbackTag(b.fallbackTag)
? resolveLoopback(b.fallbackTag)
: b.fallbackTag;
if (target) fallbackOf[b.tag] = target;
}
const result: Record<string, string[]> = {};
for (const tg of availableBalancerTags) {
const visited = new Set<string>();
let cursor = tg;
const path = [tg];
while (cursor && !visited.has(cursor)) {
if (cursor === currentTag) {
result[tg] = path;
break;
}
visited.add(cursor);
cursor = fallbackOf[cursor] || '';
if (cursor) path.push(cursor);
}
}
return result;
}, [currentTag, balancers, availableBalancerTags, templateSettings?.routing?.rules]);
const wouldCreateCycle = !!cycleInfo[fallbackTag];
const fallbackOptions = useMemo(() => {
const options: Array<{ value: string; label: ReactNode; disabled?: boolean; title?: string }> =
[{ value: '', label: `(${t('none')})` }];
for (const tg of outboundTags) {
options.push({ value: tg, label: tg });
}
for (const tg of availableBalancerTags) {
const cycle = cycleInfo[tg];
options.push({
value: tg,
disabled: !!cycle,
title: cycle
? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag })
: undefined,
label: (
<span>
<Tag color="blue" style={{ marginRight: 4 }}>
{t('pages.xray.rules.balancer')}
</Tag>
{tg}
</span>
),
});
}
return options;
}, [outboundTags, availableBalancerTags, cycleInfo, currentTag, t]);
const isFallbackBalancer = useMemo(
() => balancerTags.includes(fallbackTag),
[balancerTags, fallbackTag],
);
function submit() {
const values = methods.getValues();
const parsed = BalancerFormSchema.safeParse(values);
const trimmedTag = (values.tag ?? '').trim();
const duplicateTag = !!trimmedTag && otherTags.includes(trimmedTag);
methods.clearErrors();
if (!parsed.success) {
const seen = new Set<string>();
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? '');
if (key && !seen.has(key)) {
seen.add(key);
methods.setError(key as Path<BalancerFormValues>, { message: issue.message });
}
}
}
if (!parsed.success || duplicateTag || wouldCreateCycle) {
setSubmitAttempted(true);
return;
}
const result: BalancerFormValues = { ...parsed.data };
if (result.strategy !== 'leastLoad') delete result.settings;
onConfirm(result);
}
const title = isEdit
? `${t('edit')} ${t('pages.xray.Balancers')}`
: `+ ${t('pages.xray.Balancers')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
okButtonProps={{ disabled: wouldCreateCycle }}
mask={{ closable: false }}
onOk={submit}
onCancel={onClose}
>
<FormProvider {...methods}>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Controller
control={methods.control}
name="tag"
render={({ field, fieldState }) => {
const trimmed = (field.value ?? '').trim();
const duplicate = !!trimmed && otherTags.includes(trimmed);
const errorMessage = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: '';
const showDuplicate =
!errorMessage && (submitAttempted || fieldState.isTouched) && duplicate;
return (
<Form.Item
label={t('pages.xray.balancer.tag')}
required
validateStatus={errorMessage ? 'error' : showDuplicate ? 'warning' : ''}
help={
errorMessage || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')
}
hasFeedback
>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
ref={field.ref}
placeholder={t('pages.xray.balancer.tagPlaceholder')}
/>
</Form.Item>
);
}}
/>
<FormField name="strategy" label={t('pages.xray.balancer.balancerStrategy')}>
<Select options={STRATEGIES} />
</FormField>
<FormField name="selector" label={t('pages.xray.balancer.selector')} required>
<Select
mode="tags"
tokenSeparators={[',']}
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
/>
</FormField>
<FormField
name="fallbackTag"
label={t('pages.xray.balancer.fallback')}
extra={t('pages.xray.balancer.fallbackBalancerHint')}
transform={{ output: (v) => v ?? '' }}
>
<Select allowClear options={fallbackOptions} />
</FormField>
{isFallbackBalancer && !wouldCreateCycle && (
<Alert
type="info"
showIcon
title={t('pages.xray.balancer.balancerFallbackInfo')}
style={{ marginBottom: 16 }}
/>
)}
{wouldCreateCycle && (
<Alert
type="error"
showIcon
title={t('pages.xray.balancer.balancerFallbackCycle')}
style={{ marginBottom: 16 }}
/>
)}
{strategy === 'leastLoad' && (
<>
<FormField
name={['settings', 'expected']}
label={t('pages.xray.balancer.expected')}
transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
>
<InputNumber
min={0}
placeholder={t('pages.xray.balancer.expectedPlaceholder')}
style={{ width: '100%' }}
/>
</FormField>
<FormField
name={['settings', 'maxRTT']}
label={t('pages.xray.balancer.maxRtt')}
transform={{
input: (v) => v ?? '',
output: (v) => (typeof v === 'string' && v ? v : undefined),
}}
>
<Input placeholder="e.g. 1s" />
</FormField>
<FormField
name={['settings', 'tolerance']}
label={t('pages.xray.balancer.tolerance')}
transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
>
<InputNumber
min={0}
max={1}
step={0.01}
placeholder="0.01 = 1%"
style={{ width: '100%' }}
/>
</FormField>
<Form.Item label={t('pages.xray.balancer.baselines')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('settings.baselines', [...baselines, ''])}
/>
{baselines.map((b, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Input
value={b}
aria-label={t('pages.xray.balancer.baselines')}
placeholder="e.g. 1s"
onChange={(e) =>
methods.setValue(
'settings.baselines',
baselines.map((x, i) => (i === idx ? e.target.value : x)),
)
}
/>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'settings.baselines',
baselines.filter((_, i) => i !== idx),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.balancer.costs')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
methods.setValue('settings.costs', [
...costs,
{ regexp: false, match: '', value: 1 },
])
}
/>
{costs.map((c, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Switch
checked={c.regexp}
aria-label={t('pages.xray.balancer.costRegexp')}
checkedChildren="re"
unCheckedChildren="lit"
onChange={(v) =>
methods.setValue(
'settings.costs',
costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)),
)
}
/>
<Input
value={c.match}
aria-label={t('pages.xray.balancer.costMatch')}
placeholder="tag pattern"
onChange={(e) =>
methods.setValue(
'settings.costs',
costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)),
)
}
/>
<InputNumber
value={c.value}
aria-label={t('pages.xray.balancer.costValue')}
placeholder="weight"
style={{ width: 100 }}
onChange={(v) =>
methods.setValue(
'settings.costs',
costs.map((x, i) =>
i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x,
),
)
}
/>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'settings.costs',
costs.filter((_, i) => i !== idx),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
</>
)}
</Form>
</FormProvider>
</Modal>
);
}
@@ -1,549 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Dropdown,
Empty,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Tooltip,
message,
} from 'antd';
import {
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
SyncOutlined,
DeploymentUnitOutlined,
RadarChartOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import BalancerFormModal from './BalancerFormModal';
import type { BalancerFormValue } from './BalancerFormModal';
import { syncObservatories } from './balancer-helpers';
import {
isBalancerLoopbackTag,
loopbackTagFor,
resolveLoopbackFallback,
ensureBalancerLoopback,
removeBalancerLoopback,
removeBalancerLoopbackIfOrphaned,
propagateBalancerTagRename,
} from './balancer-loopback';
import { planBalancerDeletion, applyBalancerDeletion } from '../reference-cleanup';
import DeletionImpactList from '../DeletionImpactList';
import ObservatorySettingsTab from './ObservatorySettingsTab';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HttpUtil } from '@/utils';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import type {
BalancerObject,
BalancerStrategySettings,
BalancerStrategyType,
} from '@/schemas/routing';
// Live state of one balancer inside the running core, as reported by the
// panel's /xray/balancerStatus endpoint (RoutingService.GetBalancerInfo).
interface BalancerLiveStatus {
tag: string;
running: boolean;
override: string;
selected: string[];
}
interface BalancersTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
clientReverseTags: string[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
}
type BalancerRecord = BalancerObject;
interface BalancerRow {
key: number;
tag: string;
strategy: BalancerStrategyType;
selector: string[];
fallbackTag: string;
displayFallbackTag: string;
settings?: BalancerStrategySettings;
}
const STRATEGY_LABELS: Record<string, string> = {
random: 'Random',
roundRobin: 'Round robin',
leastLoad: 'Least load',
leastPing: 'Least ping',
};
export default function BalancersTab({
templateSettings,
setTemplateSettings,
clientReverseTags,
subscriptionOutboundTags,
isMobile,
}: BalancersTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [modalOpen, setModalOpen] = useState(false);
const [editingBalancer, setEditingBalancer] = useState<BalancerFormValue | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const balancerObjects = useMemo(
() => (templateSettings?.routing?.balancers || []) as BalancerObject[],
[templateSettings?.routing?.balancers],
);
const rows: BalancerRow[] = useMemo(() => {
const list = balancerObjects;
return list.map((b, idx) => ({
key: idx,
tag: b.tag || '',
strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType,
selector: b.selector || [],
fallbackTag: b.fallbackTag || '',
displayFallbackTag: resolveLoopbackFallback(templateSettings!, b.fallbackTag || ''),
settings: b.strategy?.settings,
}));
}, [balancerObjects, templateSettings]);
const outboundTags = useMemo(() => {
const tags = new Set<string>();
for (const o of templateSettings?.outbounds || []) {
if (o?.tag && !isBalancerLoopbackTag(o.tag)) tags.add(o.tag);
}
for (const tag of clientReverseTags || []) {
if (tag) tags.add(tag);
}
for (const tag of subscriptionOutboundTags || []) {
if (tag) tags.add(tag);
}
return [...tags];
}, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
const otherTags = useMemo(() => {
if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);
return rows
.filter((b) => b.key !== editingIndex)
.map((b) => b.tag)
.filter(Boolean);
}, [rows, editingIndex]);
const balancerTags = useMemo(() => {
return otherTags.filter((tg) => !isBalancerLoopbackTag(tg));
}, [otherTags]);
const overrideOptions: Array<{ value: string; label: React.ReactNode }> = useMemo(() => {
return outboundTags.map((tag) => ({ value: tag, label: tag }));
}, [outboundTags]);
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 [liveStatus, setLiveStatus] = useState<Record<string, BalancerLiveStatus>>({});
const [liveLoading, setLiveLoading] = useState(false);
const liveTags = useMemo(
() =>
rows
.map((r) => r.tag)
.filter(Boolean)
.join(','),
[rows],
);
const refreshLive = useCallback(async () => {
if (!liveTags) {
setLiveStatus({});
return;
}
setLiveLoading(true);
try {
const msg = await HttpUtil.post(
'/panel/api/xray/balancerStatus',
{ tags: liveTags },
{ silent: true },
);
if (msg?.success && msg.obj && typeof msg.obj === 'object') {
setLiveStatus(msg.obj as Record<string, BalancerLiveStatus>);
}
} finally {
setLiveLoading(false);
}
}, [liveTags]);
useEffect(() => {
let cancelled = false;
void (async () => {
await refreshLive();
if (cancelled) return;
})();
return () => {
cancelled = true;
};
}, [refreshLive]);
async function setOverride(tag: string, target: string) {
const msg = await HttpUtil.post('/panel/api/xray/balancerOverride', { tag, target });
if (msg?.success) await refreshLive();
}
function openAdd() {
setEditingBalancer(null);
setEditingIndex(null);
setModalOpen(true);
}
function openEdit(idx: number) {
const row = rows[idx];
const resolved: BalancerFormValue = {
...row,
fallbackTag: resolveLoopbackFallback(templateSettings!, row.fallbackTag),
};
setEditingBalancer(resolved);
setEditingIndex(idx);
setModalOpen(true);
}
function onConfirm(form: BalancerFormValue) {
mutate((tt) => {
if (!tt.routing) tt.routing = { rules: [], balancers: [] };
if (!Array.isArray(tt.routing.balancers)) tt.routing.balancers = [];
const list = tt.routing.balancers as BalancerRecord[];
const wire: BalancerRecord = {
tag: form.tag,
selector: [...form.selector],
fallbackTag: '',
};
if (form.strategy && form.strategy !== 'random') {
wire.strategy = { type: form.strategy };
if (form.strategy === 'leastLoad' && form.settings) {
wire.strategy.settings = form.settings;
}
}
const isFallbackABalancer = form.fallbackTag && balancerTags.includes(form.fallbackTag);
if (isFallbackABalancer) {
wire.fallbackTag = loopbackTagFor(form.fallbackTag);
} else {
wire.fallbackTag = form.fallbackTag || '';
}
if (editingIndex == null) {
list.push(wire);
if (isFallbackABalancer) {
ensureBalancerLoopback(tt, form.fallbackTag);
}
} else {
const oldTag = list[editingIndex]?.tag;
const oldFallback = list[editingIndex]?.fallbackTag || '';
list[editingIndex] = wire;
if (oldTag && oldTag !== wire.tag) {
const rules = tt.routing.rules || [];
for (const rule of rules) {
if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag;
}
propagateBalancerTagRename(tt, oldTag, wire.tag);
}
const oldTarget = isBalancerLoopbackTag(oldFallback) ? oldFallback.slice(4) : null;
if (oldTarget && oldTarget !== form.fallbackTag) {
removeBalancerLoopbackIfOrphaned(tt, oldTarget);
}
if (isFallbackABalancer) {
ensureBalancerLoopback(tt, form.fallbackTag);
}
}
syncObservatories(tt);
});
setModalOpen(false);
}
function confirmDelete(idx: number) {
const deletedTag = rows[idx]?.tag;
const lbTag = loopbackTagFor(deletedTag);
const dependents = (templateSettings?.routing?.balancers || [])
.filter((b) => b.tag !== deletedTag && b.fallbackTag === lbTag)
.map((b) => b.tag);
if (dependents.length > 0) {
messageApi.error(
t('pages.xray.balancer.balancerDeleteInUse', { names: dependents.join(', ') }),
);
return;
}
const impact = templateSettings
? planBalancerDeletion(templateSettings, idx)
: { rules: [], balancers: [], observatory: false, burst: false };
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
content: <DeletionImpactList impact={impact} />,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () =>
mutate((tt) => {
const tag = tt.routing?.balancers?.[idx]?.tag ?? '';
removeBalancerLoopback(tt, tag);
applyBalancerDeletion(tt, idx);
}),
});
}
const columns: ColumnsType<BalancerRow> = [
{
title: '#',
key: 'action',
align: 'center',
width: 100,
render: (_v, _record, index) => (
<div className="action-cell">
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button
aria-label={t('edit')}
shape="circle"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(index)}
/>
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEdit(index),
},
]
: []),
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => confirmDelete(index),
},
],
}}
>
<Button aria-label={t('more')} shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
{
title: 'Strategy',
key: 'strategy',
align: 'center',
width: 140,
render: (_v, record) => (
<Tag color={record.strategy === 'random' ? 'purple' : 'green'}>
{STRATEGY_LABELS[record.strategy] || record.strategy}
</Tag>
),
},
{
title: 'Selector',
key: 'selector',
align: 'center',
render: (_v, record) =>
(record.selector || []).map((sel) => (
<Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4 }}>
{sel}
</Tag>
)),
},
{
title: 'Fallback',
dataIndex: 'displayFallbackTag',
key: 'displayFallbackTag',
align: 'center',
width: 160,
},
{
title: t('pages.xray.balancerLive'),
key: 'live',
align: 'center',
width: 170,
render: (_v, record) => {
const live = liveStatus[record.tag];
if (!live?.running) {
return (
<Tooltip title={t('pages.xray.balancerNotRunning')}>
<Tag>—</Tag>
</Tooltip>
);
}
const resolve = (tag: string) =>
isBalancerLoopbackTag(tag) ? resolveLoopbackFallback(templateSettings!, tag) : tag;
const picked = live.override
? resolve(live.override)
: live.selected?.[0]
? resolve(live.selected[0])
: record.displayFallbackTag;
const tooltipText = live.override
? resolve(live.override)
: (live.selected || []).map(resolve).join(', ');
return (
<Tooltip title={tooltipText || undefined}>
<Tag color={live.override ? 'orange' : 'blue'}>{picked || '—'}</Tag>
</Tooltip>
);
},
},
{
title: t('pages.xray.balancerOverride'),
key: 'overrideTarget',
align: 'center',
width: 200,
render: (_v, record) => {
const live = liveStatus[record.tag];
const resolvedFB = record.displayFallbackTag;
let options = overrideOptions;
if (resolvedFB && !outboundTags.includes(resolvedFB)) {
options = [
...overrideOptions,
{
value: resolvedFB,
label: (
<span>
<Tag color="blue" style={{ marginRight: 4 }}>
{t('pages.xray.rules.balancer')}
</Tag>
{resolvedFB}
</span>
),
},
];
}
const rawOverride = live?.override || undefined;
const resolvedOverride =
rawOverride && isBalancerLoopbackTag(rawOverride)
? resolveLoopbackFallback(templateSettings!, rawOverride)
: rawOverride;
return (
<Select
size="small"
style={{ width: 170 }}
placeholder={t('pages.xray.balancerOverridePh')}
allowClear
disabled={!live?.running}
value={resolvedOverride}
options={options}
onChange={(v) => setOverride(record.tag, (v as string | undefined) || '')}
/>
);
},
},
];
const balancerSettingsTab = (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
{rows.length === 0 ? (
<Empty description={t('emptyBalancersDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Balancers')}
</Button>
</Empty>
) : (
<>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Balancers')}
</Button>
<Tooltip title={t('pages.xray.balancerLiveRefresh')}>
<Button
aria-label={t('pages.xray.balancerLiveRefresh')}
icon={<SyncOutlined spin={liveLoading} />}
onClick={refreshLive}
/>
</Tooltip>
</Space>
<Table
columns={columns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
size="small"
scroll={{ x: 700 }}
/>
</>
)}
</Space>
);
return (
<>
{modalContextHolder}
{messageContextHolder}
<Tabs
items={[
{
key: 'balancers',
label: catTabLabel(
<DeploymentUnitOutlined />,
t('pages.xray.tabBalancerSettings'),
isMobile,
),
children: balancerSettingsTab,
},
{
key: 'observatory',
label: catTabLabel(<RadarChartOutlined />, t('pages.xray.tabObservatory'), isMobile),
children: (
<ObservatorySettingsTab templateSettings={templateSettings} mutate={mutate} />
),
},
]}
/>
<BalancerFormModal
key={modalOpen ? `${editingIndex ?? 'new'}-${editingBalancer?.tag ?? ''}` : 'closed'}
open={modalOpen}
balancer={editingBalancer}
outboundTags={outboundTags}
balancerTags={balancerTags}
balancers={balancerObjects}
templateSettings={templateSettings}
otherTags={otherTags}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
</>
);
}
@@ -1,235 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import {
BurstObservatorySchema,
ObservatoryHttpMethodSchema,
ObservatorySchema,
type BurstObservatoryObject,
type ObservatoryHttpMethod,
type ObservatoryObject,
type PingConfigObject,
} from '@/schemas/observatory';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { settingsRequireBurstObservatory } from './balancer-helpers';
interface ObservatorySettingsTabProps {
templateSettings: XraySettingsValue | null;
mutate: (mutator: (next: XraySettingsValue) => void) => void;
}
const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({});
const BURST_DEFAULTS = BurstObservatorySchema.parse({});
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
}
function SelectorTags({ tags }: { tags: string[] }) {
if (!tags || tags.length === 0) return <Tag>—</Tag>;
return (
<>
{tags.map((sel) => (
<Tag
key={sel}
className="info-large-tag"
style={{ margin: 0, marginRight: 4, marginBottom: 4 }}
>
{sel}
</Tag>
))}
</>
);
}
export default function ObservatorySettingsTab({
templateSettings,
mutate,
}: ObservatorySettingsTabProps) {
const { t } = useTranslation();
const observatory = useMemo<ObservatoryObject | null>(() => {
const raw = templateSettings?.observatory;
if (raw == null) return null;
return { ...OBSERVATORY_DEFAULTS, ...asObject(raw) } as ObservatoryObject;
}, [templateSettings?.observatory]);
const burst = useMemo<BurstObservatoryObject | null>(() => {
const raw = templateSettings?.burstObservatory;
if (raw == null) return null;
const merged = { ...BURST_DEFAULTS, ...asObject(raw) } as BurstObservatoryObject;
merged.pingConfig = {
...BURST_DEFAULTS.pingConfig,
...asObject(merged.pingConfig),
} as PingConfigObject;
return merged;
}, [templateSettings?.burstObservatory]);
const hasObservatory = observatory != null;
const hasBurst = burst != null;
const hasMixedObservers = hasObservatory && hasBurst;
const activeView =
hasBurst && (!hasObservatory || settingsRequireBurstObservatory(templateSettings))
? 'burstObservatory'
: 'observatory';
function patchObservatory(patch: Partial<ObservatoryObject>) {
mutate((tt) => {
tt.observatory = { ...OBSERVATORY_DEFAULTS, ...asObject(tt.observatory), ...patch };
});
}
function patchPingConfig(patch: Partial<PingConfigObject>) {
mutate((tt) => {
const current = asObject(tt.burstObservatory);
const currentPing = asObject(current.pingConfig);
tt.burstObservatory = {
...BURST_DEFAULTS,
...current,
pingConfig: { ...BURST_DEFAULTS.pingConfig, ...currentPing, ...patch },
};
});
}
if (!hasObservatory && !hasBurst) {
return <Empty description={t('pages.xray.observatory.emptyHint')} />;
}
const observatorySection = observatory && (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.subjectSelector')}
description={t('pages.xray.observatory.subjectSelectorDesc')}
>
<SelectorTags tags={observatory.subjectSelector} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.probeURL')}
description={t('pages.xray.observatory.probeURLDesc')}
>
<Input
value={observatory.probeURL}
onChange={(e) => patchObservatory({ probeURL: e.target.value })}
placeholder="https://www.google.com/generate_204"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.probeInterval')}
description={t('pages.xray.observatory.probeIntervalDesc')}
>
<Input
value={observatory.probeInterval}
onChange={(e) => patchObservatory({ probeInterval: e.target.value })}
placeholder="1m"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.enableConcurrency')}
description={t('pages.xray.observatory.enableConcurrencyDesc')}
>
<Switch
checked={observatory.enableConcurrency}
onChange={(v) => patchObservatory({ enableConcurrency: v })}
/>
</SettingListItem>
</>
);
const burstSection = burst && (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.subjectSelector')}
description={t('pages.xray.observatory.subjectSelectorDesc')}
>
<SelectorTags tags={burst.subjectSelector} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.destination')}
description={t('pages.xray.observatory.destinationDesc')}
>
<Input
value={burst.pingConfig.destination}
onChange={(e) => patchPingConfig({ destination: e.target.value })}
placeholder="https://www.google.com/generate_204"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.connectivity')}
description={t('pages.xray.observatory.connectivityDesc')}
>
<Input
value={burst.pingConfig.connectivity}
allowClear
onChange={(e) => patchPingConfig({ connectivity: e.target.value })}
placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.interval')}
description={t('pages.xray.observatory.intervalDesc')}
>
<Input
value={burst.pingConfig.interval}
onChange={(e) => patchPingConfig({ interval: e.target.value })}
placeholder="1m"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.timeout')}
description={t('pages.xray.observatory.timeoutDesc')}
>
<Input
value={burst.pingConfig.timeout}
onChange={(e) => patchPingConfig({ timeout: e.target.value })}
placeholder="5s"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.sampling')}
description={t('pages.xray.observatory.samplingDesc')}
>
<InputNumber
min={1}
value={burst.pingConfig.sampling}
onChange={onNumber((v) => patchPingConfig({ sampling: v }))}
style={{ width: '100%' }}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.httpMethod')}
description={t('pages.xray.observatory.httpMethodDesc')}
>
<Select<ObservatoryHttpMethod>
value={burst.pingConfig.httpMethod}
onChange={(v) => patchPingConfig({ httpMethod: v })}
options={ObservatoryHttpMethodSchema.options.map((m) => ({ value: m, label: m }))}
style={{ width: '100%' }}
/>
</SettingListItem>
</>
);
return (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Alert type="info" showIcon title={t('pages.xray.observatory.autoManaged')} />
{hasMixedObservers && (
<Alert type="warning" showIcon title={t('pages.xray.observatory.mixedLegacy')} />
)}
<div>{activeView === 'observatory' ? observatorySection : burstSection}</div>
</Space>
);
}
@@ -1,2 +0,0 @@
export { default as BalancersTab } from './BalancersTab';
export { default as BalancerFormModal } from './BalancerFormModal';
@@ -1,26 +0,0 @@
.preset-warning {
margin-bottom: 12px;
}
.preset-list {
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
overflow: hidden;
}
.preset-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.preset-row:last-child {
border-bottom: 0;
}
.preset-name {
font-weight: 500;
}
@@ -1,113 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Alert, Button, Modal, Space, Tag } from 'antd';
import './DnsPresetsModal.css';
interface DnsPresetsModalProps {
open: boolean;
onClose: () => void;
onInstall: (servers: string[]) => void;
}
export const PRESETS: { name: string; tags: string[]; data: string[] }[] = [
{
name: 'Google DNS',
tags: ['UDP'],
data: ['8.8.8.8', '8.8.4.4', '2001:4860:4860::8888', '2001:4860:4860::8844'],
},
{
name: 'Cloudflare DNS',
tags: ['UDP'],
data: ['1.1.1.1', '1.0.0.1', '2606:4700:4700::1111', '2606:4700:4700::1001'],
},
{
name: 'AdGuard DNS',
tags: ['UDP'],
data: ['94.140.14.14', '94.140.15.15', '2a10:50c0::ad1:ff', '2a10:50c0::ad2:ff'],
},
{
name: 'AdGuard Family DNS',
tags: ['UDP', 'Family'],
data: ['94.140.14.15', '94.140.15.16', '2a10:50c0::bad1:ff', '2a10:50c0::bad2:ff'],
},
{
name: 'Cloudflare Family DNS',
tags: ['UDP', 'Family'],
data: ['1.1.1.3', '1.0.0.3', '2606:4700:4700::1113', '2606:4700:4700::1003'],
},
{
name: 'Cloudflare DoH',
tags: ['DoH'],
data: ['https://cloudflare-dns.com/dns-query'],
},
{
name: 'Google DoH',
tags: ['DoH'],
data: ['https://dns.google/dns-query'],
},
{
name: 'Quad9 Secure DoH',
tags: ['DoH', 'Malware'],
data: ['https://dns.quad9.net/dns-query'],
},
{
name: 'AdGuard DoH + DoQ',
tags: ['DoH', 'DoQ', 'Ads'],
data: ['https://dns.adguard-dns.com/dns-query', 'quic+local://dns.adguard-dns.com'],
},
{
name: 'Control D Ads DoH + DoQ',
tags: ['DoH', 'DoQ', 'Ads'],
data: ['https://freedns.controld.com/p2', 'quic+local://p2.freedns.controld.com'],
},
{
name: 'Control D Family DoH + DoQ',
tags: ['DoH', 'DoQ', 'Family'],
data: ['https://freedns.controld.com/p4', 'quic+local://p4.freedns.controld.com'],
},
];
function tagLabel(tag: string, t: (key: string) => string): string {
if (tag === 'Family') return t('pages.xray.dns.dnsPresetFamily');
return tag;
}
export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresetsModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
title={t('pages.xray.dns.dnsPresetTitle')}
footer={null}
mask={{ closable: false }}
onCancel={onClose}
>
<Alert
type="warning"
showIcon
className="preset-warning"
title={t('pages.xray.dns.dnsLeakWarning')}
/>
<div className="preset-list">
{PRESETS.map((preset) => (
<div key={preset.name} className="preset-row">
<Space size="small" align="center">
{preset.tags.map((tag) => (
<Tag
key={tag}
color={tag === 'Family' ? 'purple' : tag === 'UDP' ? 'orange' : 'green'}
>
{tagLabel(tag, t)}
</Tag>
))}
<span className="preset-name">{preset.name}</span>
</Space>
<Button type="primary" size="small" onClick={() => onInstall([...preset.data])}>
{t('install')}
</Button>
</div>
))}
</div>
</Modal>
);
}
@@ -1,313 +0,0 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import {
DnsQueryStrategySchema,
DnsServerObjectInnerSchema,
DnsServerObjectSchema,
isEncryptedDnsAddress,
type DnsServerObject,
} from '@/schemas/dns';
export type DnsServerValue =
| string
| (DnsServerObject & {
expectIPs?: string[];
[key: string]: unknown;
});
interface DnsServerModalProps {
open: boolean;
server: DnsServerValue | null;
isEdit: boolean;
onClose: () => void;
onConfirm: (value: DnsServerValue) => void;
}
const STRATEGIES = DnsQueryStrategySchema.options;
type DnsServerForm = {
address: string;
port: number;
domains: string[];
expectedIPs: string[];
unexpectedIPs: string[];
queryStrategy: string;
skipFallback: boolean;
disableCache: boolean;
finalQuery: boolean;
tag: string;
clientIP: string;
serveStale: boolean;
serveExpiredTTL: number;
timeoutMs: number;
};
function defaultFormValues(): DnsServerForm {
return {
address: 'localhost',
port: 53,
domains: [],
expectedIPs: [],
unexpectedIPs: [],
queryStrategy: 'UseIP',
skipFallback: false,
disableCache: false,
finalQuery: false,
tag: '',
clientIP: '',
serveStale: false,
serveExpiredTTL: 0,
timeoutMs: 4000,
};
}
function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
if (server == null) return defaultFormValues();
if (typeof server === 'string') return { ...defaultFormValues(), address: server };
const parsed = DnsServerObjectSchema.safeParse(server);
const data = parsed.success ? parsed.data : null;
return {
...defaultFormValues(),
...(data ?? {}),
address: (data?.address ?? server.address) || 'localhost',
domains: data?.domains ?? server.domains ?? [],
expectedIPs: data?.expectedIPs ?? server.expectedIPs ?? server.expectIPs ?? [],
unexpectedIPs: data?.unexpectedIPs ?? server.unexpectedIPs ?? [],
queryStrategy: data?.queryStrategy ?? server.queryStrategy ?? 'UseIP',
skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
disableCache: data?.disableCache ?? server.disableCache ?? false,
finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
tag: data?.tag ?? server.tag ?? '',
clientIP: data?.clientIP ?? server.clientIP ?? '',
serveStale: data?.serveStale ?? server.serveStale ?? false,
serveExpiredTTL: data?.serveExpiredTTL ?? server.serveExpiredTTL ?? 0,
timeoutMs: data?.timeoutMs ?? server.timeoutMs ?? 4000,
};
}
function valuesToWire(values: DnsServerForm): DnsServerValue {
const isPlain =
values.domains.length === 0 &&
values.expectedIPs.length === 0 &&
values.unexpectedIPs.length === 0 &&
values.port === 53 &&
values.queryStrategy === 'UseIP' &&
values.skipFallback === false &&
values.disableCache === false &&
values.finalQuery === false &&
!values.tag &&
!values.clientIP &&
values.serveStale === false &&
values.serveExpiredTTL === 0 &&
values.timeoutMs === 4000;
if (isPlain) return values.address;
const out: Record<string, unknown> = {
address: values.address,
domains: values.domains.filter(Boolean),
expectedIPs: values.expectedIPs.filter(Boolean),
unexpectedIPs: values.unexpectedIPs.filter(Boolean),
queryStrategy: values.queryStrategy,
skipFallback: values.skipFallback,
disableCache: values.disableCache,
finalQuery: values.finalQuery,
serveStale: values.serveStale,
serveExpiredTTL: values.serveExpiredTTL,
timeoutMs: values.timeoutMs,
};
if (!isEncryptedDnsAddress(values.address)) out.port = values.port;
if (values.tag) out.tag = values.tag;
if (values.clientIP) out.clientIP = values.clientIP;
return out as DnsServerValue;
}
const shape = DnsServerObjectInnerSchema.shape;
export default function DnsServerModal({
open,
server,
isEdit,
onClose,
onConfirm,
}: DnsServerModalProps) {
const { t } = useTranslation();
const methods = useForm<DnsServerForm>({ defaultValues: defaultFormValues() });
const domains = useWatch({ control: methods.control, name: 'domains' }) ?? [];
const expectedIPs = useWatch({ control: methods.control, name: 'expectedIPs' }) ?? [];
const unexpectedIPs = useWatch({ control: methods.control, name: 'unexpectedIPs' }) ?? [];
useEffect(() => {
if (!open) return;
methods.reset(valuesFromServer(server));
}, [open, server, methods]);
const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
return (
<Modal
open={open}
title={title}
okText={t('confirm')}
cancelText={t('close')}
mask={{ closable: false }}
onOk={methods.handleSubmit((values) => onConfirm(valuesToWire(values)))}
onCancel={onClose}
>
<FormProvider {...methods}>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<FormField
label={t('pages.inbounds.address')}
name="address"
rules={{ validate: rhfZodValidate(shape.address) }}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.port')}
name="port"
rules={{ validate: rhfZodValidate(shape.port) }}
>
<InputNumber min={1} max={65535} />
</FormField>
<FormField label={t('pages.xray.dns.tag')} name="tag">
<Input />
</FormField>
<FormField label={t('pages.xray.dns.clientIp')} name="clientIP">
<Input />
</FormField>
<FormField label={t('pages.xray.dns.strategy')} name="queryStrategy">
<Select
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
/>
</FormField>
<FormField
label={t('pages.xray.dns.timeoutMs')}
name="timeoutMs"
rules={{ validate: rhfZodValidate(shape.timeoutMs) }}
>
<InputNumber min={0} step={500} />
</FormField>
<Divider style={{ margin: '5px 0' }} />
<Form.Item label={t('pages.xray.dns.domains')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('domains', [...domains, ''])}
/>
{domains.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`domains.${i}`} noStyle>
<Input />
</FormField>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'domains',
domains.filter((__, idx) => idx !== i),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.dns.expectIPs')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])}
/>
{expectedIPs.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`expectedIPs.${i}`} noStyle>
<Input />
</FormField>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'expectedIPs',
expectedIPs.filter((__, idx) => idx !== i),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])}
/>
{unexpectedIPs.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`unexpectedIPs.${i}`} noStyle>
<Input />
</FormField>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'unexpectedIPs',
unexpectedIPs.filter((__, idx) => idx !== i),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Divider style={{ margin: '5px 0' }} />
<FormField
label={t('pages.xray.dns.skipFallback')}
name="skipFallback"
valueProp="checked"
>
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.finalQuery')} name="finalQuery" valueProp="checked">
<Switch />
</FormField>
<FormField
label={t('pages.xray.dns.disableCache')}
name="disableCache"
valueProp="checked"
>
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.serveStale')} name="serveStale" valueProp="checked">
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
<InputNumber min={0} step={60} />
</FormField>
</Form>
</FormProvider>
</Modal>
);
}
-16
View File
@@ -1,16 +0,0 @@
.row-index {
font-weight: 500;
opacity: 0.7;
}
.muted {
opacity: 0.7;
word-break: break-all;
}
.hosts-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
-516
View File
@@ -1,516 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Empty,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Table,
Tabs,
} from 'antd';
import {
DatabaseOutlined,
DeleteOutlined,
ExperimentOutlined,
MenuOutlined,
PlusOutlined,
ProfileOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import DnsServerModal from './DnsServerModal';
import type { DnsServerValue } from './DnsServerModal';
import DnsPresetsModal from './DnsPresetsModal';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './DnsTab.css';
import { STRATEGIES, DEFAULT_FAKEDNS } from './helpers';
import type { DnsConfig, HostRow, FakednsRow } from './types';
import { useDnsServerColumns, useFakednsColumns } from './useDnsColumns';
interface DnsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
}
export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [hostsList, setHostsList] = useState<HostRow[]>([]);
const [serverModalOpen, setServerModalOpen] = useState(false);
const [editingServer, setEditingServer] = useState<DnsServerValue | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [presetsModalOpen, setPresetsModalOpen] = useState(false);
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
const dnsEnabled = !!dns;
const sourceHosts = dns?.hosts;
const incomingHosts = JSON.stringify(sourceHosts ?? {});
const lastWrittenHostsRef = useRef<string | null>(null);
useEffect(() => {
if (!dnsEnabled) {
lastWrittenHostsRef.current = '{}';
setHostsList([]);
return;
}
if (incomingHosts === lastWrittenHostsRef.current) return;
lastWrittenHostsRef.current = incomingHosts;
setHostsList(
Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
domain,
values: Array.isArray(values) ? [...values] : [String(values)],
})),
);
}, [dnsEnabled, incomingHosts, sourceHosts]);
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 toggleDNS(enabled: boolean) {
mutate((next) => {
if (enabled) {
(next as { dns?: DnsConfig }).dns = {
tag: 'dns_inbound',
queryStrategy: 'UseIP',
disableCache: false,
disableFallback: false,
disableFallbackIfMatch: false,
useSystemHosts: false,
enableParallelQuery: false,
serveStale: false,
serveExpiredTTL: 0,
hosts: {},
servers: [],
};
next.fakedns = null;
} else {
delete next.dns;
delete next.fakedns;
}
});
}
function syncHosts(next: HostRow[]) {
const obj: Record<string, string | string[]> = {};
for (const row of next) {
if (!row.domain) continue;
const vals = (row.values || []).filter(Boolean);
if (vals.length === 0) continue;
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
}
lastWrittenHostsRef.current = JSON.stringify(obj);
setHostsList(next);
mutate((tt) => {
if (!tt.dns) return;
if (Object.keys(obj).length > 0) {
(tt.dns as DnsConfig).hosts = obj;
} else if ('hosts' in (tt.dns as DnsConfig)) {
delete (tt.dns as DnsConfig).hosts;
}
});
}
function setDnsField<K extends keyof DnsConfig>(key: K, value: DnsConfig[K], omit = false) {
mutate((tt) => {
if (!tt.dns) return;
if (omit && (value == null || (typeof value === 'string' && value.trim() === ''))) {
delete (tt.dns as Record<string, unknown>)[key as string];
} else {
(tt.dns as Record<string, unknown>)[key as string] = value;
}
});
}
const dnsServers = useMemo(() => {
const list = dns?.servers || [];
return list.map((server, idx) => ({ key: idx, server }));
}, [dns?.servers]);
// Stable callbacks: the column definitions in useDnsServerColumns are
// memoized, so they must be able to depend on these (see issue #5155)
const openEditServer = useCallback(
(idx: number) => {
setEditingServer((dns?.servers || [])[idx] || null);
setEditingIndex(idx);
setServerModalOpen(true);
},
[dns?.servers],
);
const deleteServer = useCallback(
(idx: number) => {
mutate((tt) => {
const cfg = tt.dns as DnsConfig | undefined;
if (cfg?.servers) cfg.servers.splice(idx, 1);
});
},
[mutate],
);
const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer });
function openAddServer() {
setEditingServer(null);
setEditingIndex(null);
setServerModalOpen(true);
}
function onServerConfirm(value: DnsServerValue) {
mutate((tt) => {
if (!tt.dns) return;
const cfg = tt.dns as DnsConfig;
if (!Array.isArray(cfg.servers)) cfg.servers = [];
if (editingIndex == null) cfg.servers.push(value);
else cfg.servers[editingIndex] = value;
});
setServerModalOpen(false);
}
function clearAllServers() {
modal.confirm({
title: t('pages.xray.dns.clearAllTitle'),
content: t('pages.xray.dns.clearAllConfirm'),
okText: t('delete'),
okButtonProps: { danger: true },
cancelText: t('cancel'),
onOk: () =>
mutate((tt) => {
if (tt.dns) (tt.dns as DnsConfig).servers = [];
}),
});
}
function onPresetInstall(servers: string[]) {
mutate((tt) => {
if (tt.dns) (tt.dns as DnsConfig).servers = servers;
});
setPresetsModalOpen(false);
}
const fakeDnsList = useMemo<{ key: number; ipPool: string; poolSize: number }[]>(() => {
const list = Array.isArray(templateSettings?.fakedns)
? (templateSettings?.fakedns as FakednsRow[])
: [];
return list.map((entry, idx) => ({ key: idx, ...entry }));
}, [templateSettings?.fakedns]);
const deleteFakedns = useCallback(
(idx: number) => {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list) return;
list.splice(idx, 1);
if (list.length === 0) tt.fakedns = null;
});
},
[mutate],
);
const updateFakednsField = useCallback(
(idx: number, field: 'ipPool' | 'poolSize', value: string | number) => {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list?.[idx]) return;
(list[idx] as unknown as Record<string, unknown>)[field] = value;
});
},
[mutate],
);
const fakednsColumns = useFakednsColumns({ deleteFakedns, updateFakednsField });
function addFakedns() {
mutate((tt) => {
if (!Array.isArray(tt.fakedns)) tt.fakedns = [];
(tt.fakedns as FakednsRow[]).push(DEFAULT_FAKEDNS());
});
}
const items = useMemo(() => {
const out = [
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.enable')}
description={t('pages.xray.dns.enableDesc')}
control={<Switch checked={dnsEnabled} onChange={toggleDNS} />}
/>
{dnsEnabled && (
<>
<Alert
type="warning"
showIcon
title={t('pages.xray.dns.dnsLeakWarning')}
style={{ marginBottom: 12 }}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.tag')}
description={t('pages.xray.dns.tagDesc')}
control={
<Input
value={dns?.tag ?? 'dns_inbound'}
onChange={(e) => setDnsField('tag', e.target.value)}
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.clientIp')}
description={t('pages.xray.dns.clientIpDesc')}
control={
<Input
value={dns?.clientIp ?? ''}
onChange={(e) => setDnsField('clientIp', e.target.value, true)}
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.strategy')}
description={t('pages.xray.dns.strategyDesc')}
control={
<Select
value={dns?.queryStrategy ?? 'UseIP'}
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
onChange={(v) => setDnsField('queryStrategy', v)}
/>
}
/>
{(
[
[
'disableCache',
'pages.xray.dns.disableCache',
'pages.xray.dns.disableCacheDesc',
],
[
'disableFallback',
'pages.xray.dns.disableFallback',
'pages.xray.dns.disableFallbackDesc',
],
[
'disableFallbackIfMatch',
'pages.xray.dns.disableFallbackIfMatch',
'pages.xray.dns.disableFallbackIfMatchDesc',
],
[
'enableParallelQuery',
'pages.xray.dns.enableParallelQuery',
'pages.xray.dns.enableParallelQueryDesc',
],
[
'useSystemHosts',
'pages.xray.dns.useSystemHosts',
'pages.xray.dns.useSystemHostsDesc',
],
['serveStale', 'pages.xray.dns.serveStale', 'pages.xray.dns.serveStaleDesc'],
] as const
).map(([field, titleKey, descKey]) => (
<SettingListItem
key={field}
paddings="small"
title={t(titleKey)}
description={t(descKey)}
control={
<Switch
checked={!!dns?.[field]}
onChange={(v) => setDnsField(field as keyof DnsConfig, v as never)}
/>
}
/>
))}
<SettingListItem
paddings="small"
title={t('pages.xray.dns.serveExpiredTTL')}
description={t('pages.xray.dns.serveExpiredTTLDesc')}
control={
<InputNumber
value={dns?.serveExpiredTTL ?? 0}
min={0}
step={60}
style={{ width: '100%' }}
onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
/>
}
/>
</>
)}
</>
),
},
];
if (dnsEnabled) {
out.push({
key: 'hosts',
label: catTabLabel(<ProfileOutlined />, t('pages.xray.dns.hosts'), isMobile),
children:
hostsList.length === 0 ? (
<Empty description={t('pages.xray.dns.hostsEmpty')}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
>
{t('pages.xray.dns.hostsAdd')}
</Button>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
>
{t('pages.xray.dns.hostsAdd')}
</Button>
{hostsList.map((row, idx) => (
<div key={`h${idx}`} className="hosts-row">
<Input
value={row.domain}
aria-label={t('pages.xray.dns.hostsDomain')}
placeholder={t('pages.xray.dns.hostsDomain')}
style={{ flex: '1 1 220px' }}
onChange={(e) => {
const next = hostsList.map((r, i) =>
i === idx ? { ...r, domain: e.target.value } : r,
);
syncHosts(next);
}}
/>
<Select
mode="tags"
value={row.values}
aria-label={t('pages.xray.dns.hostsValues')}
placeholder={t('pages.xray.dns.hostsValues')}
style={{ flex: '2 1 320px' }}
tokenSeparators={[',', ' ']}
onChange={(values) => {
const next = hostsList.map((r, i) => (i === idx ? { ...r, values } : r));
syncHosts(next);
}}
/>
<Button
danger
aria-label={t('delete')}
icon={<DeleteOutlined />}
onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))}
/>
</div>
))}
</Space>
),
});
out.push({
key: '2',
label: catTabLabel(<DatabaseOutlined />, 'DNS', isMobile),
children:
dnsServers.length === 0 ? (
<Empty description={t('emptyDnsDesc')}>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
{t('pages.xray.dns.add')}
</Button>
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
{t('pages.xray.dns.usePreset')}
</Button>
</Space>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
{t('pages.xray.dns.add')}
</Button>
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
{t('pages.xray.dns.usePreset')}
</Button>
<Button danger icon={<DeleteOutlined />} onClick={clearAllServers}>
{t('pages.xray.dns.clearAll')}
</Button>
</Space>
<Table
columns={dnsColumns}
dataSource={dnsServers}
rowKey={(r) => r.key}
pagination={false}
size="small"
bordered
/>
</Space>
),
});
out.push({
key: '3',
label: catTabLabel(<ExperimentOutlined />, 'Fake DNS', isMobile),
children:
fakeDnsList.length === 0 ? (
<Empty description={t('emptyFakeDnsDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')}
</Button>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')}
</Button>
<Table
columns={fakednsColumns}
dataSource={fakeDnsList}
rowKey={(r) => r.key}
pagination={false}
size="small"
bordered
/>
</Space>
),
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, isMobile, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
return (
<>
{modalContextHolder}
<Tabs defaultActiveKey="1" items={items} />
<DnsServerModal
open={serverModalOpen}
server={editingServer}
isEdit={editingIndex != null}
onClose={() => setServerModalOpen(false)}
onConfirm={onServerConfirm}
/>
<DnsPresetsModal
open={presetsModalOpen}
onClose={() => setPresetsModalOpen(false)}
onInstall={onPresetInstall}
/>
</>
);
}
-19
View File
@@ -1,19 +0,0 @@
import { DnsQueryStrategySchema } from '@/schemas/dns';
import type { DnsServerValue } from './DnsServerModal';
export const STRATEGIES = DnsQueryStrategySchema.options;
export const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
export function addrFor(server: DnsServerValue): string {
return typeof server === 'string' ? server : server?.address || '';
}
export function domainsFor(server: DnsServerValue): string {
return typeof server === 'object' && server !== null ? (server.domains || []).join(',') : '';
}
export function expectedIPsFor(server: DnsServerValue): string {
if (typeof server !== 'object' || !server) return '';
const list = server.expectedIPs || server.expectIPs || [];
return Array.isArray(list) ? list.join(',') : '';
}
-3
View File
@@ -1,3 +0,0 @@
export { default as DnsTab } from './DnsTab';
export { default as DnsServerModal } from './DnsServerModal';
export { default as DnsPresetsModal } from './DnsPresetsModal';
-14
View File
@@ -1,14 +0,0 @@
import type { DnsObject } from '@/schemas/dns';
import type { DnsServerValue } from './DnsServerModal';
export type DnsConfig = Omit<DnsObject, 'servers'> & { servers?: DnsServerValue[] };
export interface HostRow {
domain: string;
values: string[];
}
export interface FakednsRow {
ipPool: string;
poolSize: number;
}
@@ -1,149 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { onNumber } from '@/utils/onNumber';
import { addrFor, domainsFor, expectedIPsFor } from './helpers';
import type { DnsServerValue } from './DnsServerModal';
export type DnsServerRow = { key: number; server: DnsServerValue };
export type FakednsTableRow = { key: number; ipPool: string; poolSize: number };
export function useDnsServerColumns({
openEditServer,
deleteServer,
}: {
openEditServer: (idx: number) => void;
deleteServer: (idx: number) => void;
}): ColumnsType<DnsServerRow> {
const { t } = useTranslation();
return useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEditServer(index),
},
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => deleteServer(index),
},
],
}}
>
<Button aria-label={t('more')} shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</Space>
),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => addrFor(record.server),
},
{
title: t('pages.xray.dns.domains'),
key: 'domains',
align: 'left',
render: (_v, record) => <span className="muted">{domainsFor(record.server)}</span>,
},
{
title: t('pages.xray.dns.expectIPs'),
key: 'expectedIPs',
align: 'left',
render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
},
],
[t, openEditServer, deleteServer],
);
}
export function useFakednsColumns({
deleteFakedns,
updateFakednsField,
}: {
deleteFakedns: (idx: number) => void;
updateFakednsField: (idx: number, field: 'ipPool' | 'poolSize', value: string | number) => void;
}): ColumnsType<FakednsTableRow> {
const { t } = useTranslation();
return useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Button
aria-label={t('delete')}
shape="circle"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => deleteFakedns(index)}
/>
</Space>
),
},
{
title: 'IP pool',
dataIndex: 'ipPool',
key: 'ipPool',
align: 'left',
render: (_v, record, index) => (
<Input
value={record.ipPool}
aria-label={t('pages.xray.fakedns.ipPool')}
size="small"
onChange={(e) => updateFakednsField(index, 'ipPool', e.target.value)}
/>
),
},
{
title: 'Pool size',
dataIndex: 'poolSize',
key: 'poolSize',
align: 'right',
width: 120,
render: (_v, record, index) => (
<InputNumber
value={record.poolSize}
aria-label={t('pages.xray.fakedns.poolSize')}
min={1}
size="small"
onChange={onNumber((v) => updateFakednsField(index, 'poolSize', v))}
/>
),
},
],
[t, deleteFakedns, updateFakednsField],
);
}
+1 -4
View File
@@ -1,7 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { AllSetting } from '@/models/setting';
import SecurityTab from '@/pages/settings/SecurityTab';
import { HttpUtil } from '@/utils';
@@ -26,9 +25,7 @@ describe('API token creation date', () => {
],
});
render(
<SecurityTab allSetting={{} as AllSetting} updateSetting={vi.fn()} saveSetting={vi.fn()} />,
);
render(<SecurityTab />);
fireEvent.click(screen.getByRole('tab', { name: /API Token/ }));
expect(await screen.findByText('seconds-token')).toBeTruthy();
@@ -1,93 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { fireEvent } from '@testing-library/react';
import BalancerFormModal from '@/pages/xray/balancers/BalancerFormModal';
import type { BalancerFormValue } from '@/pages/xray/balancers/BalancerFormModal';
import type { BalancerObject } from '@/schemas/routing';
import { renderWithProviders } from './test-utils';
function renderModal(onConfirm = vi.fn()) {
renderWithProviders(
<BalancerFormModal
open
balancer={null}
outboundTags={['proxy', 'direct']}
balancerTags={[]}
balancers={[]}
templateSettings={null}
otherTags={['existing']}
onClose={() => {}}
onConfirm={onConfirm}
/>,
);
return { onConfirm };
}
function erroredItemCount(): number {
return document.querySelectorAll('.ant-form-item-has-error').length;
}
function explainText(): string {
return Array.from(document.querySelectorAll('.ant-form-item-explain'))
.map((el) => (el.textContent ?? '').trim())
.join(' | ');
}
function primaryButton(): HTMLElement {
const btn = document.querySelector('.ant-modal-footer .ant-btn-primary');
if (!btn) throw new Error('Primary button not found');
return btn as HTMLElement;
}
describe('BalancerFormModal', () => {
it('shows no validation errors when freshly opened in add mode', () => {
renderModal();
expect(document.querySelector('.ant-modal')).toBeTruthy();
expect(erroredItemCount()).toBe(0);
expect(explainText()).not.toContain('Tag is required');
expect(explainText()).not.toContain('Pick at least one outbound');
expect(primaryButton().hasAttribute('disabled')).toBe(false);
});
it('reveals required-field errors only after a save attempt, without confirming', () => {
const { onConfirm } = renderModal();
expect(erroredItemCount()).toBe(0);
fireEvent.click(primaryButton());
expect(erroredItemCount()).toBe(2);
expect(explainText()).toContain('Tag is required');
expect(explainText()).toContain('Pick at least one outbound');
expect(onConfirm).not.toHaveBeenCalled();
});
it('disables save and warns when the chosen fallback would create a balancer cycle', () => {
const editing: BalancerFormValue = {
tag: 'A',
strategy: 'random',
selector: ['proxy'],
fallbackTag: 'B',
};
const others: BalancerObject[] = [{ tag: 'B', selector: ['direct'], fallbackTag: 'A' }];
const onConfirm = vi.fn();
renderWithProviders(
<BalancerFormModal
open
balancer={editing}
outboundTags={['proxy', 'direct']}
balancerTags={['A', 'B']}
balancers={others}
templateSettings={null}
otherTags={['B']}
onClose={() => {}}
onConfirm={onConfirm}
/>,
);
expect(document.querySelector('.ant-alert-error')).toBeTruthy();
expect(primaryButton().hasAttribute('disabled')).toBe(true);
fireEvent.click(primaryButton());
expect(onConfirm).not.toHaveBeenCalled();
});
});
-19
View File
@@ -1,19 +0,0 @@
import { describe, expect, it } from 'vitest';
import { PRESETS } from '@/pages/xray/dns/DnsPresetsModal';
import { DnsObjectSchema, DnsServerObjectSchema } from '@/schemas/dns';
describe('DNS presets', () => {
it('include encrypted presets accepted by xray DNS config', () => {
const servers = PRESETS.flatMap((preset) => preset.data);
expect(servers).toContain('https://freedns.controld.com/p2');
expect(servers).toContain('quic+local://p2.freedns.controld.com');
expect(servers).toContain('https://dns.google/dns-query');
expect(servers.every((server) => !server.startsWith('tls://'))).toBe(true);
expect(DnsObjectSchema.parse({ servers }).servers).toEqual(servers);
for (const server of servers) {
expect(DnsServerObjectSchema.parse({ address: server }).address).toBe(server);
}
});
});
-130
View File
@@ -1,130 +0,0 @@
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import { fireEvent, screen } from '@testing-library/react';
import DnsTab from '@/pages/xray/dns/DnsTab';
import type { SetTemplate, XraySettingsValue } from '@/hooks/useXraySetting';
import { renderWithProviders } from './test-utils';
function withHosts(hosts: Record<string, string>): XraySettingsValue {
return {
dns: {
hosts,
servers: [],
},
} as unknown as XraySettingsValue;
}
describe('DnsTab', () => {
it('keeps an empty row after adding a host', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
withHosts({ 'first.example': '1.1.1.1' }),
);
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.click(screen.getByRole('button', { name: /Add Host$/ }));
expect(screen.getAllByLabelText('Domain (e.g. domain:example.com)')).toHaveLength(2);
});
it('keeps a row visible while its domain is incomplete', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
withHosts({ 'first.example': '1.1.1.1' }),
);
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return <DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />;
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), {
target: { value: '' },
});
expect(
(screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value,
).toBe('');
});
it('shows hosts from an externally refreshed configuration', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
withHosts({ 'first.example': '1.1.1.1' }),
);
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return (
<>
<button
type="button"
onClick={() => setTemplateSettings(withHosts({ 'second.example': '2.2.2.2' }))}
>
Refresh hosts
</button>
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
</>
);
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
expect(
(screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value,
).toBe('first.example');
fireEvent.click(screen.getByRole('button', { name: 'Refresh hosts' }));
expect(
(screen.getByLabelText('Domain (e.g. domain:example.com)') as HTMLInputElement).value,
).toBe('second.example');
});
it('clears an incomplete host draft when DNS is disabled', () => {
function Harness() {
const [templateSettings, setTemplateSettings] = useState<XraySettingsValue | null>(
withHosts({ 'first.example': '1.1.1.1' }),
);
const updateTemplate: SetTemplate = (next) => {
setTemplateSettings((current) => (typeof next === 'function' ? next(current) : next));
};
return (
<>
<button type="button" onClick={() => setTemplateSettings({})}>
Disable DNS
</button>
<button type="button" onClick={() => setTemplateSettings(withHosts({}))}>
Enable DNS
</button>
<DnsTab templateSettings={templateSettings} setTemplateSettings={updateTemplate} />
</>
);
}
renderWithProviders(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
fireEvent.change(screen.getByLabelText('Domain (e.g. domain:example.com)'), {
target: { value: '' },
});
fireEvent.click(screen.getByRole('button', { name: 'Disable DNS' }));
fireEvent.click(screen.getByRole('button', { name: 'Enable DNS' }));
fireEvent.click(screen.getByRole('tab', { name: /Hosts$/ }));
expect(screen.queryByLabelText('Domain (e.g. domain:example.com)')).toBeNull();
});
});
@@ -1,48 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import ObservatorySettingsTab from '@/pages/xray/balancers/ObservatorySettingsTab';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { renderWithProviders } from './test-utils';
function renderTab(templateSettings: XraySettingsValue) {
renderWithProviders(
<ObservatorySettingsTab templateSettings={templateSettings} mutate={vi.fn()} />,
);
}
describe('ObservatorySettingsTab', () => {
it('shows one burst settings panel and warns for legacy mixed observers', () => {
renderTab({
routing: {
balancers: [{ tag: 'll', selector: ['proxy-a'], strategy: { type: 'leastLoad' } }],
},
observatory: { subjectSelector: ['stale-regular'] },
burstObservatory: { subjectSelector: ['proxy-a'] },
} as unknown as XraySettingsValue);
expect(
screen.getByText(/This config contains both Observatory and Burst Observatory/),
).toBeTruthy();
expect(document.querySelector('.ant-segmented')).toBeFalsy();
expect(screen.getByText('Probe Destination')).toBeTruthy();
expect(screen.queryByText('Probe URL')).toBeFalsy();
});
it('shows regular observatory settings for mixed legacy configs that normalize back to leastPing only', () => {
renderTab({
routing: {
balancers: [{ tag: 'lp', selector: ['proxy-b'], strategy: { type: 'leastPing' } }],
},
observatory: { subjectSelector: ['proxy-b'] },
burstObservatory: { subjectSelector: ['stale-burst'] },
} as unknown as XraySettingsValue);
expect(
screen.getByText(/This config contains both Observatory and Burst Observatory/),
).toBeTruthy();
expect(document.querySelector('.ant-segmented')).toBeFalsy();
expect(screen.getByText('Probe URL')).toBeTruthy();
expect(screen.queryByText('Probe Destination')).toBeFalsy();
});
});