精简版第四轮补裁(P4): 页面层摘除 + Xray更新端点下线 + 本机核心监管下线
页面层(用户真机截图发现前两轮漏裁): - 设置: 摘除 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:
@@ -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": [
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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 }))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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(',') : '';
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as DnsTab } from './DnsTab';
|
||||
export { default as DnsServerModal } from './DnsServerModal';
|
||||
export { default as DnsPresetsModal } from './DnsPresetsModal';
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user