Compare commits
55
Commits
v3.7.0-agent.1
...
V1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e89960474 | ||
|
|
afb5456b8f | ||
|
|
36acc4c2b1 | ||
|
|
7f098c6c4e | ||
|
|
942b220f80 | ||
|
|
7d9a38490d | ||
|
|
3df652a67d | ||
|
|
c91fb454a8 | ||
|
|
c490bdcd76 | ||
|
|
5c432205ad | ||
|
|
8901267867 | ||
|
|
e310abcba0 | ||
|
|
ced5e69de0 | ||
|
|
a82d1e56fa | ||
|
|
7e7ab2ecbd | ||
|
|
d46035708e | ||
|
|
2596880327 | ||
|
|
2365eb138b | ||
|
|
27a5097756 | ||
|
|
f18aca9410 | ||
|
|
29b724dae1 | ||
|
|
1edb8f71da | ||
|
|
8900ef68fc | ||
|
|
e5f39ed20b | ||
|
|
487b074801 | ||
|
|
3536d4174e | ||
|
|
84d7b85be7 | ||
|
|
81d89840e4 | ||
|
|
ff1204ce2d | ||
|
|
58cdfe73b0 | ||
|
|
ac39370879 | ||
|
|
f569680182 | ||
|
|
9adffd049c | ||
|
|
8ca1a4647a | ||
|
|
0ed6d8a1cf | ||
|
|
dcfaf6ace6 | ||
|
|
7d03749246 | ||
|
|
5efaaef2c2 | ||
|
|
bea7f4b2b6 | ||
|
|
051a74be80 | ||
|
|
d867b15f4a | ||
|
|
4da8d58307 | ||
|
|
6a880aed09 | ||
|
|
1035d00fe4 | ||
|
|
f6d652019d | ||
|
|
ceca96571d | ||
|
|
5031a70ccb | ||
|
|
6772c97f8e | ||
|
|
7284fa6cf0 | ||
|
|
261a1f6787 | ||
|
|
60ce5eab98 | ||
|
|
f887f47bb9 | ||
|
|
2c85f85549 | ||
|
|
0f0335e943 | ||
|
|
4828f946b1 |
+8
-5
@@ -24,13 +24,16 @@
|
||||
- 安全设置:双重验证(2FA,用户不用)
|
||||
- 邮件模块(SMTP 发送:到期/流量提醒等,用户不用)
|
||||
- 分组功能(客户端归类标签 + 按组批量操作/聚合订阅;用户从未用过,裁功能不影响已建客户端与订阅)
|
||||
- 本地核心实测三件套(P9 第 4 条,用户 2026-10-21 晚改口):出站测速(临时 xray 实例批量探测 `testOutbound(s)` 接口 + 前端测试按钮/模式切换/结果弹层)、出口探测(同一动作的 egress IP/国家展示)、重启/停止核心接口(`restartXrayService`/`stopXrayService`,与首页已摘按钮同族);出站与路由的模板编辑(增删改存)保留不动
|
||||
- 本地核心整条路径(P9 第 4b 条):进程管理/状态机、buildXrayConfig 本地分支与 local-only 注入(panel/node egress 桥、订阅出站合并、mtproto/amneziawg 桥)、本地核心专属接口(balancer 状态/override、routeTest、getXrayResult)与前端 RouteTester、本地核心三个 job(流量采集/存活看门狗/客户端 IP 限制)已整条删除;配置构建只剩 agent 一条线(模板 + 节点入站 + egress + 转发编译)。MTProto/AmneziaWG 随之退役:新建/编辑直接拒绝,存量行保留在库中可删除、不再被服务。金标准验证:同一种子库删除前后三台 agent 的生成配置逐字一致
|
||||
|
||||
## 待用户确认
|
||||
## 范围确认(2026-10-21 用户拍板:四项全部保留,不裁)
|
||||
|
||||
- 协议范围:是否只留 VLESS + SS2022,裁其余协议模板(VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等)——建议裁
|
||||
- IP 检查 / 测速类后台任务——建议裁
|
||||
- 面板内置 ACME 签证书(走 CF Tunnel,不直签)——建议裁
|
||||
- 多用户功能(单管理员使用)——建议裁
|
||||
- 协议范围:保留全部协议模板(VLESS、SS2022、VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等),不收窄
|
||||
- IP 检查 / 测速类后台任务:保留 → **2026-10-21 晚用户改口推翻本项**:出站测速 + 出口探测(同一动作,测的是主控本地网络位置;精简主控不跑本机核心、无本地出站,测速结果无意义)连同重启/停止核心控件与接口,已于 P9 第 4 条摘除。其余三项(协议范围 / ACME / 多用户)保留不变,不要再翻出来问。
|
||||
- 面板内置 ACME 签证书:保留
|
||||
- 多用户功能:保留
|
||||
- 用户理由:这四块以静态代码为主,裁掉省不了多少内存。此节关闭,后续轮次不要再翻出来问。
|
||||
|
||||
## 修复(非裁剪,已定)
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@ cp geoip.dat geosite.dat /usr/local/x-ui/bin/
|
||||
```bash
|
||||
mkdir -p /usr/local/x-ui/bin /etc/x-ui
|
||||
|
||||
# 1. 放同一个 x-ui 二进制(与主控同版本)
|
||||
cp x-ui-linux-amd64 /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
|
||||
# 1. 放同一个二进制(与主控同版本),在节点上改名为 xui-agent(与主控进程区分、避免同机覆盖)
|
||||
cp x-ui-linux-amd64 /usr/local/x-ui/xui-agent && chmod +x /usr/local/x-ui/xui-agent
|
||||
|
||||
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,需手动放置)
|
||||
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
|
||||
|
||||
+2
-2
@@ -37,8 +37,8 @@ curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
|
||||
```bash
|
||||
mkdir -p /usr/local/x-ui/bin /etc/x-ui
|
||||
|
||||
# 1. 放 x-ui 二进制(agent 补丁版,与主控同版本)
|
||||
cp x-ui /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
|
||||
# 1. 放 x-ui 二进制(agent 补丁版,与主控同版本),在节点上改名为 xui-agent(与主控进程区分)
|
||||
cp x-ui /usr/local/x-ui/xui-agent && chmod +x /usr/local/x-ui/xui-agent
|
||||
|
||||
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,手动放置)
|
||||
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "npm run gen:api && vite build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "oxlint src tools",
|
||||
"lint:fix": "oxlint --fix src tools",
|
||||
@@ -22,8 +22,7 @@
|
||||
"test:watch": "vitest",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"gen": "npm run gen:zod && npm run gen:api",
|
||||
"gen:api": "node scripts/build-openapi.mjs",
|
||||
"gen": "npm run gen:zod",
|
||||
"gen:zod": "cd .. && go run ./tools/openapigen",
|
||||
"prepare": "cd .. && husky frontend/.husky || true"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,246 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { sections } from '../src/pages/api-docs/endpoints.ts';
|
||||
import { EXAMPLES } from '../src/generated/examples.ts';
|
||||
import { SCHEMAS } from '../src/generated/schemas.ts';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const outPath = join(__dirname, '..', 'public', 'openapi.json');
|
||||
|
||||
const PANEL_VERSION = process.env.X_UI_VERSION || '3.x';
|
||||
|
||||
const SECURITY_SCHEMES = {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
description: 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
|
||||
},
|
||||
cookieAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: '3x-ui',
|
||||
description: 'Session cookie set by POST /login. Browser-only.',
|
||||
},
|
||||
};
|
||||
|
||||
function ginPathToOpenApi(path) {
|
||||
return path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
|
||||
}
|
||||
|
||||
function extractPathParams(openApiPath) {
|
||||
const params = [];
|
||||
const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
||||
let m;
|
||||
while ((m = re.exec(openApiPath)) !== null) params.push(m[1]);
|
||||
return params;
|
||||
}
|
||||
|
||||
function mapType(t) {
|
||||
const v = String(t || '').toLowerCase();
|
||||
if (v.endsWith('[]')) return 'array';
|
||||
if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
|
||||
if (v === 'float' || v === 'double') return 'number';
|
||||
if (v === 'boolean' || v === 'bool') return 'boolean';
|
||||
if (v === 'array') return 'array';
|
||||
if (v === 'object') return 'object';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function schemaFromType(t) {
|
||||
const v = String(t || '').toLowerCase();
|
||||
if (v.endsWith('[]')) {
|
||||
const itemType = v.slice(0, -2);
|
||||
return { type: 'array', items: { type: mapType(itemType) } };
|
||||
}
|
||||
return { type: mapType(v) };
|
||||
}
|
||||
|
||||
function tryParseJson(raw) {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function paramToOpenApi(p) {
|
||||
const out = {
|
||||
name: p.name,
|
||||
in: p.in,
|
||||
required: p.in === 'path' ? true : !p.optional,
|
||||
description: p.desc || '',
|
||||
schema: schemaFromType(p.type),
|
||||
};
|
||||
if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildOperation(ep, tag) {
|
||||
const op = {
|
||||
tags: [tag],
|
||||
summary: ep.summary || '',
|
||||
operationId: `${ep.method.toLowerCase()}_${ep.path.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
|
||||
};
|
||||
if (ep.description) op.description = ep.description;
|
||||
if (ep.deprecated) op.deprecated = true;
|
||||
|
||||
const params = [];
|
||||
const bodyParams = [];
|
||||
for (const p of ep.params || []) {
|
||||
if (p.in === 'body') {
|
||||
bodyParams.push(p);
|
||||
} else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
|
||||
params.push(paramToOpenApi(p));
|
||||
}
|
||||
}
|
||||
|
||||
const openApiPath = ginPathToOpenApi(ep.path);
|
||||
const declared = new Set(params.filter((x) => x.in === 'path').map((x) => x.name));
|
||||
for (const name of extractPathParams(openApiPath)) {
|
||||
if (declared.has(name)) continue;
|
||||
params.push({
|
||||
name,
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: '',
|
||||
schema: { type: 'string' },
|
||||
});
|
||||
}
|
||||
|
||||
if (params.length > 0) op.parameters = params;
|
||||
|
||||
if (ep.body || bodyParams.length > 0) {
|
||||
const example = tryParseJson(ep.body);
|
||||
const properties = {};
|
||||
const required = [];
|
||||
for (const bp of bodyParams) {
|
||||
properties[bp.name] = {
|
||||
...schemaFromType(bp.type),
|
||||
description: bp.desc || '',
|
||||
};
|
||||
if (!bp.optional) required.push(bp.name);
|
||||
}
|
||||
const schema = bodyParams.length > 0
|
||||
? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
|
||||
: { type: 'object' };
|
||||
|
||||
op.requestBody = {
|
||||
required: required.length > 0 || bodyParams.length === 0,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema,
|
||||
...(example !== undefined ? { example } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const responses = {};
|
||||
let successExample = tryParseJson(ep.response);
|
||||
let objSchema = {};
|
||||
if (ep.responseSchema) {
|
||||
const obj = EXAMPLES[ep.responseSchema];
|
||||
if (obj === undefined) {
|
||||
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
|
||||
}
|
||||
if (SCHEMAS[ep.responseSchema] === undefined) {
|
||||
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
|
||||
}
|
||||
const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
|
||||
objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
|
||||
if (successExample === undefined) {
|
||||
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
|
||||
}
|
||||
}
|
||||
responses['200'] = {
|
||||
description: 'Successful response',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
msg: { type: 'string' },
|
||||
obj: objSchema,
|
||||
},
|
||||
},
|
||||
...(successExample !== undefined ? { example: successExample } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const errExample = tryParseJson(ep.errorResponse);
|
||||
if (errExample !== undefined || ep.errorStatus) {
|
||||
const code = String(ep.errorStatus || 400);
|
||||
responses[code] = {
|
||||
description: 'Error response',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
msg: { type: 'string' },
|
||||
},
|
||||
},
|
||||
...(errExample !== undefined ? { example: errExample } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
op.responses = responses;
|
||||
return op;
|
||||
}
|
||||
|
||||
function buildSpec() {
|
||||
const paths = {};
|
||||
for (const section of sections) {
|
||||
const tag = section.title;
|
||||
for (const ep of section.endpoints) {
|
||||
const openApiPath = ginPathToOpenApi(ep.path);
|
||||
if (!paths[openApiPath]) paths[openApiPath] = {};
|
||||
paths[openApiPath][ep.method.toLowerCase()] = buildOperation(ep, tag);
|
||||
}
|
||||
}
|
||||
|
||||
const tags = sections.map((s) => ({
|
||||
name: s.title,
|
||||
description: s.description || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: '3X-UI Panel API',
|
||||
version: PANEL_VERSION,
|
||||
description:
|
||||
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
|
||||
},
|
||||
servers: [
|
||||
{ url: '/', description: 'Current panel (basePath aware)' },
|
||||
],
|
||||
components: {
|
||||
securitySchemes: SECURITY_SCHEMES,
|
||||
schemas: SCHEMAS,
|
||||
},
|
||||
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
|
||||
tags,
|
||||
paths,
|
||||
};
|
||||
}
|
||||
|
||||
const spec = buildSpec();
|
||||
writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
|
||||
|
||||
const pathCount = Object.keys(spec.paths).length;
|
||||
let opCount = 0;
|
||||
for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
|
||||
console.log(`[openapi] wrote ${outPath}`);
|
||||
console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
|
||||
|
||||
void pathToFileURL;
|
||||
@@ -36,7 +36,6 @@ export const keys = {
|
||||
onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
|
||||
activeInbounds: () => ['clients', 'activeInbounds'] as const,
|
||||
lastOnline: () => ['clients', 'lastOnline'] as const,
|
||||
groups: () => ['clients', 'groups'] as const,
|
||||
},
|
||||
xray: {
|
||||
root: () => ['xray'] as const,
|
||||
|
||||
@@ -3,64 +3,24 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"AllSetting": {
|
||||
"datepicker": "",
|
||||
"expireDiff": 0,
|
||||
"externalTrafficInformEnable": false,
|
||||
"externalTrafficInformURI": "",
|
||||
"ipLimitAllowlist": "",
|
||||
"ldapAutoCreate": false,
|
||||
"ldapAutoDelete": false,
|
||||
"ldapBaseDN": "",
|
||||
"ldapBindDN": "",
|
||||
"ldapDefaultExpiryDays": 0,
|
||||
"ldapDefaultLimitIP": 0,
|
||||
"ldapDefaultTotalGB": 0,
|
||||
"ldapEnable": false,
|
||||
"ldapFlagField": "",
|
||||
"ldapHost": "",
|
||||
"ldapInboundTags": "",
|
||||
"ldapInsecureSkipVerify": false,
|
||||
"ldapInvertFlag": false,
|
||||
"ldapPassword": "",
|
||||
"ldapPort": 0,
|
||||
"ldapSyncCron": "",
|
||||
"ldapTruthyValues": "",
|
||||
"ldapUseTLS": false,
|
||||
"ldapUserAttr": "",
|
||||
"ldapUserFilter": "",
|
||||
"ldapVlessField": "",
|
||||
"outboundDownThreshold": 1,
|
||||
"pageSize": 0,
|
||||
"panelOutbound": "",
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"smtpCpu": 0,
|
||||
"smtpEnable": false,
|
||||
"smtpEnabledEvents": "",
|
||||
"smtpEncryptionType": "",
|
||||
"smtpFrom": "",
|
||||
"smtpFromName": "",
|
||||
"smtpHost": "",
|
||||
"smtpMemory": 0,
|
||||
"smtpPassword": "",
|
||||
"smtpPort": 1,
|
||||
"smtpTo": "",
|
||||
"smtpUsername": "",
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
"subClashAutoDetect": false,
|
||||
"subClashEnable": false,
|
||||
"subClashEnableRouting": false,
|
||||
"subClashPath": "",
|
||||
"subClashRules": "",
|
||||
"subClashURI": "",
|
||||
"subClashUserAgentRegex": "",
|
||||
"subDomain": "",
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
"subEncrypt": false,
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonEnable": false,
|
||||
@@ -68,15 +28,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subJsonMux": "",
|
||||
"subJsonObservatory": "",
|
||||
"subJsonPath": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonURI": "",
|
||||
"subJsonUserAgentRegex": "",
|
||||
"subKeyFile": "",
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
"subPort": 1,
|
||||
"subPort": 0,
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
@@ -97,8 +55,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"timeLocation": "",
|
||||
"trafficDiff": 0,
|
||||
"trustedProxyCIDRs": "",
|
||||
"twoFactorEnable": false,
|
||||
"twoFactorToken": "",
|
||||
"warpUpdateInterval": 0,
|
||||
"webBasePath": "",
|
||||
"webCertFile": "",
|
||||
@@ -110,71 +66,28 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"AllSettingView": {
|
||||
"datepicker": "",
|
||||
"expireDiff": 0,
|
||||
"externalTrafficInformEnable": false,
|
||||
"externalTrafficInformURI": "",
|
||||
"hasApiToken": false,
|
||||
"hasLdapPassword": false,
|
||||
"hasNordSecret": false,
|
||||
"hasSmtpPassword": false,
|
||||
"hasTgBotToken": false,
|
||||
"hasTwoFactorToken": false,
|
||||
"hasWarpSecret": false,
|
||||
"ipLimitAllowlist": "",
|
||||
"ldapAutoCreate": false,
|
||||
"ldapAutoDelete": false,
|
||||
"ldapBaseDN": "",
|
||||
"ldapBindDN": "",
|
||||
"ldapDefaultExpiryDays": 0,
|
||||
"ldapDefaultLimitIP": 0,
|
||||
"ldapDefaultTotalGB": 0,
|
||||
"ldapEnable": false,
|
||||
"ldapFlagField": "",
|
||||
"ldapHost": "",
|
||||
"ldapInboundTags": "",
|
||||
"ldapInsecureSkipVerify": false,
|
||||
"ldapInvertFlag": false,
|
||||
"ldapPassword": "",
|
||||
"ldapPort": 0,
|
||||
"ldapSyncCron": "",
|
||||
"ldapTruthyValues": "",
|
||||
"ldapUseTLS": false,
|
||||
"ldapUserAttr": "",
|
||||
"ldapUserFilter": "",
|
||||
"ldapVlessField": "",
|
||||
"outboundDownThreshold": 1,
|
||||
"pageSize": 0,
|
||||
"panelOutbound": "",
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"smtpCpu": 0,
|
||||
"smtpEnable": false,
|
||||
"smtpEnabledEvents": "",
|
||||
"smtpEncryptionType": "",
|
||||
"smtpFrom": "",
|
||||
"smtpFromName": "",
|
||||
"smtpHost": "",
|
||||
"smtpMemory": 0,
|
||||
"smtpPassword": "",
|
||||
"smtpPort": 1,
|
||||
"smtpTo": "",
|
||||
"smtpUsername": "",
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
"subClashAutoDetect": false,
|
||||
"subClashEnable": false,
|
||||
"subClashEnableRouting": false,
|
||||
"subClashPath": "",
|
||||
"subClashRules": "",
|
||||
"subClashURI": "",
|
||||
"subClashUserAgentRegex": "",
|
||||
"subDomain": "",
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
"subEncrypt": false,
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonEnable": false,
|
||||
@@ -182,15 +95,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subJsonMux": "",
|
||||
"subJsonObservatory": "",
|
||||
"subJsonPath": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonURI": "",
|
||||
"subJsonUserAgentRegex": "",
|
||||
"subKeyFile": "",
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
"subPort": 1,
|
||||
"subPort": 0,
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
@@ -211,8 +122,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"timeLocation": "",
|
||||
"trafficDiff": 0,
|
||||
"trustedProxyCIDRs": "",
|
||||
"twoFactorEnable": false,
|
||||
"twoFactorToken": "",
|
||||
"warpUpdateInterval": 0,
|
||||
"webBasePath": "",
|
||||
"webCertFile": "",
|
||||
@@ -221,26 +130,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"webListen": "",
|
||||
"webPort": 1
|
||||
},
|
||||
"AmneziaWGLogs": {
|
||||
"events": [
|
||||
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
|
||||
],
|
||||
"peers": [
|
||||
{
|
||||
"allowedIPs": "10.8.1.2/32",
|
||||
"down": 4194304,
|
||||
"email": "peer@example.com",
|
||||
"endpoint": "203.0.113.9:51820",
|
||||
"handshake": 1735732800000,
|
||||
"inboundId": 1,
|
||||
"interface": "awg1",
|
||||
"online": true,
|
||||
"tag": "inbound-51820",
|
||||
"up": 1048576
|
||||
}
|
||||
],
|
||||
"running": true
|
||||
},
|
||||
"ApiToken": {
|
||||
"createdAt": 0,
|
||||
"enabled": false,
|
||||
@@ -530,6 +419,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"enable": true,
|
||||
"expiryTime": 0,
|
||||
"fallbackParent": null,
|
||||
"forwarding": "",
|
||||
"id": 1,
|
||||
"lastTrafficResetTime": 0,
|
||||
"listen": "",
|
||||
@@ -648,9 +538,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
""
|
||||
],
|
||||
"name": "",
|
||||
"nodeType": "traditional",
|
||||
"outboundTag": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 1,
|
||||
"port": 0,
|
||||
"remark": "",
|
||||
"scheme": "http",
|
||||
"tlsVerifyMode": "verify"
|
||||
@@ -683,6 +574,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"name": "edge-1",
|
||||
"netDown": 1048576,
|
||||
"netUp": 2097152,
|
||||
"nodeType": "traditional",
|
||||
"onlineCount": 5,
|
||||
"outboundTag": "direct",
|
||||
"panelVersion": "v3.x.x",
|
||||
@@ -713,18 +605,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"runId": "1735689600123456789",
|
||||
"state": "success"
|
||||
},
|
||||
"PeerActivity": {
|
||||
"allowedIPs": "10.8.1.2/32",
|
||||
"down": 4194304,
|
||||
"email": "peer@example.com",
|
||||
"endpoint": "203.0.113.9:51820",
|
||||
"handshake": 1735732800000,
|
||||
"inboundId": 1,
|
||||
"interface": "awg1",
|
||||
"online": true,
|
||||
"tag": "inbound-51820",
|
||||
"up": 1048576
|
||||
},
|
||||
"ProbeResultUI": {
|
||||
"cpuPct": 12.5,
|
||||
"error": "",
|
||||
|
||||
@@ -9,83 +9,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"externalTrafficInformURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"ipLimitAllowlist": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapAutoCreate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapAutoDelete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapBaseDN": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapBindDN": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapDefaultExpiryDays": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapDefaultLimitIP": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapDefaultTotalGB": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapFlagField": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapHost": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInboundTags": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInsecureSkipVerify": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapInvertFlag": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapPassword": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapPort": {
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapSyncCron": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapTruthyValues": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapUseTLS": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapUserAttr": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapUserFilter": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
@@ -110,48 +36,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpCpu": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"smtpEnabledEvents": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpEncryptionType": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFromName": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpMemory": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPort": {
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpTo": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"type": "string"
|
||||
},
|
||||
"subAnnounce": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -164,15 +48,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subClashEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashURI": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -185,21 +63,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subEncrypt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subIncyEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -221,9 +90,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonURI": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -240,16 +106,14 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"subPort": {
|
||||
"description": "SubPort 0 means \"no explicit port\": subscription links are generated\nwithout a port (standard scheme port behind a reverse proxy) while the\nsub server still binds the default port. See SettingService.BuildSubURIBase\nand the sub server bootstrap for the two sides of that split.",
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -318,12 +182,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"trustedProxyCIDRs": {
|
||||
"type": "string"
|
||||
},
|
||||
"twoFactorEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"twoFactorToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"warpUpdateInterval": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
@@ -352,64 +210,24 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"required": [
|
||||
"datepicker",
|
||||
"expireDiff",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"ipLimitAllowlist",
|
||||
"ldapAutoCreate",
|
||||
"ldapAutoDelete",
|
||||
"ldapBaseDN",
|
||||
"ldapBindDN",
|
||||
"ldapDefaultExpiryDays",
|
||||
"ldapDefaultLimitIP",
|
||||
"ldapDefaultTotalGB",
|
||||
"ldapEnable",
|
||||
"ldapFlagField",
|
||||
"ldapHost",
|
||||
"ldapInboundTags",
|
||||
"ldapInsecureSkipVerify",
|
||||
"ldapInvertFlag",
|
||||
"ldapPassword",
|
||||
"ldapPort",
|
||||
"ldapSyncCron",
|
||||
"ldapTruthyValues",
|
||||
"ldapUseTLS",
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"smtpCpu",
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpFrom",
|
||||
"smtpFromName",
|
||||
"smtpHost",
|
||||
"smtpMemory",
|
||||
"smtpPassword",
|
||||
"smtpPort",
|
||||
"smtpTo",
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashAutoDetect",
|
||||
"subClashEnable",
|
||||
"subClashEnableRouting",
|
||||
"subClashPath",
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subClashUserAgentRegex",
|
||||
"subDomain",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
@@ -417,7 +235,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonMux",
|
||||
"subJsonObservatory",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
"subJsonUserAgentRegex",
|
||||
"subKeyFile",
|
||||
@@ -425,7 +242,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
@@ -446,8 +262,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"timeLocation",
|
||||
"trafficDiff",
|
||||
"trustedProxyCIDRs",
|
||||
"twoFactorEnable",
|
||||
"twoFactorToken",
|
||||
"warpUpdateInterval",
|
||||
"webBasePath",
|
||||
"webCertFile",
|
||||
@@ -467,104 +281,21 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"externalTrafficInformURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"hasApiToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasLdapPassword": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasNordSecret": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasSmtpPassword": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasTgBotToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasTwoFactorToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasWarpSecret": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ipLimitAllowlist": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapAutoCreate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapAutoDelete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapBaseDN": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapBindDN": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapDefaultExpiryDays": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapDefaultLimitIP": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapDefaultTotalGB": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapFlagField": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapHost": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInboundTags": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInsecureSkipVerify": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapInvertFlag": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapPassword": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapPort": {
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapSyncCron": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapTruthyValues": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapUseTLS": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapUserAttr": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapUserFilter": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
@@ -589,48 +320,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpCpu": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"smtpEnabledEvents": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpEncryptionType": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFromName": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpMemory": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPort": {
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpTo": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"type": "string"
|
||||
},
|
||||
"subAnnounce": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -643,15 +332,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subClashEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashURI": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -664,21 +347,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subEncrypt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subIncyEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -700,9 +374,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonURI": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -719,16 +390,14 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"subPort": {
|
||||
"description": "SubPort 0 means \"no explicit port\": subscription links are generated\nwithout a port (standard scheme port behind a reverse proxy) while the\nsub server still binds the default port. See SettingService.BuildSubURIBase\nand the sub server bootstrap for the two sides of that split.",
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -797,12 +466,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"trustedProxyCIDRs": {
|
||||
"type": "string"
|
||||
},
|
||||
"twoFactorEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"twoFactorToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"warpUpdateInterval": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
@@ -831,71 +494,28 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"required": [
|
||||
"datepicker",
|
||||
"expireDiff",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"hasApiToken",
|
||||
"hasLdapPassword",
|
||||
"hasNordSecret",
|
||||
"hasSmtpPassword",
|
||||
"hasTgBotToken",
|
||||
"hasTwoFactorToken",
|
||||
"hasWarpSecret",
|
||||
"ipLimitAllowlist",
|
||||
"ldapAutoCreate",
|
||||
"ldapAutoDelete",
|
||||
"ldapBaseDN",
|
||||
"ldapBindDN",
|
||||
"ldapDefaultExpiryDays",
|
||||
"ldapDefaultLimitIP",
|
||||
"ldapDefaultTotalGB",
|
||||
"ldapEnable",
|
||||
"ldapFlagField",
|
||||
"ldapHost",
|
||||
"ldapInboundTags",
|
||||
"ldapInsecureSkipVerify",
|
||||
"ldapInvertFlag",
|
||||
"ldapPassword",
|
||||
"ldapPort",
|
||||
"ldapSyncCron",
|
||||
"ldapTruthyValues",
|
||||
"ldapUseTLS",
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"smtpCpu",
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpFrom",
|
||||
"smtpFromName",
|
||||
"smtpHost",
|
||||
"smtpMemory",
|
||||
"smtpPassword",
|
||||
"smtpPort",
|
||||
"smtpTo",
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashAutoDetect",
|
||||
"subClashEnable",
|
||||
"subClashEnableRouting",
|
||||
"subClashPath",
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subClashUserAgentRegex",
|
||||
"subDomain",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
@@ -903,7 +523,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonMux",
|
||||
"subJsonObservatory",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
"subJsonUserAgentRegex",
|
||||
"subKeyFile",
|
||||
@@ -911,7 +530,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
@@ -932,8 +550,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"timeLocation",
|
||||
"trafficDiff",
|
||||
"trustedProxyCIDRs",
|
||||
"twoFactorEnable",
|
||||
"twoFactorToken",
|
||||
"warpUpdateInterval",
|
||||
"webBasePath",
|
||||
"webCertFile",
|
||||
@@ -944,36 +560,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AmneziaWGLogs": {
|
||||
"description": "AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live\nper-peer activity of every running embedded interface, plus the panel's\nown recent AmneziaWG lifecycle log lines that explain a peer being absent\nfrom Peers at all.",
|
||||
"properties": {
|
||||
"events": {
|
||||
"example": [
|
||||
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"peers": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PeerActivity"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"running": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"events",
|
||||
"peers",
|
||||
"running"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ApiToken": {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
@@ -2061,6 +1647,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "FallbackParent is populated by the API layer when this inbound is\nattached as a fallback child of a VLESS/Trojan TCP-TLS master.\nThe frontend uses it to rewrite client-share links so they advertise\nthe master's externally reachable endpoint instead of the child's\nloopback listen. Not persisted.",
|
||||
"nullable": true
|
||||
},
|
||||
"forwarding": {
|
||||
"description": "Forwarding holds the per-inbound forwarding form as JSON\n(service.ForwardingSpec). Empty means direct egress. Panel-side\nmetadata only: when an agent pulls its config, the entry agent's\noutbounds/routing gain a compiled entry for this inbound — the raw\nstring itself never reaches xray.",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "Unique identifier",
|
||||
"example": 1,
|
||||
@@ -2171,6 +1761,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"down",
|
||||
"enable",
|
||||
"expiryTime",
|
||||
"forwarding",
|
||||
"id",
|
||||
"lastTrafficResetTime",
|
||||
"listen",
|
||||
@@ -2617,6 +2208,14 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "NodeType distinguishes traditional panel-to-panel nodes from lightweight\nagent nodes. Agent nodes skip the panel reachability probe and the API\ntoken requirement; an agent token is issued on creation instead.",
|
||||
"enum": [
|
||||
"traditional",
|
||||
"agent"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"outboundTag": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2625,7 +2224,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"port": {
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
@@ -2778,6 +2377,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "NodeType is \"agent\" for lightweight agent-managed nodes (an agent is\nbound to the node) and \"traditional\" otherwise. It is derived from the\nagent binding, not stored on the node row; read paths that serve the\npanel UI annotate it via annotateNodeTypes. Empty on views built\noutside those paths.",
|
||||
"example": "traditional",
|
||||
"type": "string"
|
||||
},
|
||||
"onlineCount": {
|
||||
"example": 5,
|
||||
"type": "integer"
|
||||
@@ -2945,68 +2549,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PeerActivity": {
|
||||
"description": "PeerActivity is one peer's live embedded-Device-reported state, the\ncounterpart of an Xray access-log entry: a tunnel logs no requests, only\nhandshakes and bytes.",
|
||||
"properties": {
|
||||
"allowedIPs": {
|
||||
"example": "10.8.1.2/32",
|
||||
"type": "string"
|
||||
},
|
||||
"down": {
|
||||
"example": 4194304,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"email": {
|
||||
"example": "peer@example.com",
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"example": "203.0.113.9:51820",
|
||||
"type": "string"
|
||||
},
|
||||
"handshake": {
|
||||
"description": "Handshake is unix milliseconds, 0 when the peer has never connected.",
|
||||
"example": 1735732800000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundId": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"interface": {
|
||||
"example": "awg1",
|
||||
"type": "string"
|
||||
},
|
||||
"online": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"tag": {
|
||||
"example": "inbound-51820",
|
||||
"type": "string"
|
||||
},
|
||||
"up": {
|
||||
"example": 1048576,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"allowedIPs",
|
||||
"down",
|
||||
"email",
|
||||
"endpoint",
|
||||
"handshake",
|
||||
"inboundId",
|
||||
"interface",
|
||||
"online",
|
||||
"tag",
|
||||
"up"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProbeResultUI": {
|
||||
"properties": {
|
||||
"cpuPct": {
|
||||
|
||||
@@ -4,6 +4,7 @@ export type OnlineAPISupport = number;
|
||||
export type ProcessState = string;
|
||||
export type Protocol = string;
|
||||
export type SubLinkProvider = unknown;
|
||||
export type rawBytes = number[];
|
||||
export type staticEgressResolver = string;
|
||||
export type trafficLocalApplyAction = number;
|
||||
export type transportBits = number;
|
||||
@@ -11,64 +12,24 @@ export type transportBits = number;
|
||||
export interface AllSetting {
|
||||
datepicker: string;
|
||||
expireDiff: number;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
ipLimitAllowlist: string;
|
||||
ldapAutoCreate: boolean;
|
||||
ldapAutoDelete: boolean;
|
||||
ldapBaseDN: string;
|
||||
ldapBindDN: string;
|
||||
ldapDefaultExpiryDays: number;
|
||||
ldapDefaultLimitIP: number;
|
||||
ldapDefaultTotalGB: number;
|
||||
ldapEnable: boolean;
|
||||
ldapFlagField: string;
|
||||
ldapHost: string;
|
||||
ldapInboundTags: string;
|
||||
ldapInsecureSkipVerify: boolean;
|
||||
ldapInvertFlag: boolean;
|
||||
ldapPassword: string;
|
||||
ldapPort: number;
|
||||
ldapSyncCron: string;
|
||||
ldapTruthyValues: string;
|
||||
ldapUseTLS: boolean;
|
||||
ldapUserAttr: string;
|
||||
ldapUserFilter: string;
|
||||
ldapVlessField: string;
|
||||
outboundDownThreshold: number;
|
||||
pageSize: number;
|
||||
panelOutbound: string;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
smtpCpu: number;
|
||||
smtpEnable: boolean;
|
||||
smtpEnabledEvents: string;
|
||||
smtpEncryptionType: string;
|
||||
smtpFrom: string;
|
||||
smtpFromName: string;
|
||||
smtpHost: string;
|
||||
smtpMemory: number;
|
||||
smtpPassword: string;
|
||||
smtpPort: number;
|
||||
smtpTo: string;
|
||||
smtpUsername: string;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashAutoDetect: boolean;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subClashUserAgentRegex: string;
|
||||
subDomain: string;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonEnable: boolean;
|
||||
@@ -76,7 +37,6 @@ export interface AllSetting {
|
||||
subJsonMux: string;
|
||||
subJsonObservatory: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
subJsonUserAgentRegex: string;
|
||||
subKeyFile: string;
|
||||
@@ -84,7 +44,6 @@ export interface AllSetting {
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
@@ -105,8 +64,6 @@ export interface AllSetting {
|
||||
timeLocation: string;
|
||||
trafficDiff: number;
|
||||
trustedProxyCIDRs: string;
|
||||
twoFactorEnable: boolean;
|
||||
twoFactorToken: string;
|
||||
warpUpdateInterval: number;
|
||||
webBasePath: string;
|
||||
webCertFile: string;
|
||||
@@ -119,71 +76,28 @@ export interface AllSetting {
|
||||
export interface AllSettingView {
|
||||
datepicker: string;
|
||||
expireDiff: number;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
hasApiToken: boolean;
|
||||
hasLdapPassword: boolean;
|
||||
hasNordSecret: boolean;
|
||||
hasSmtpPassword: boolean;
|
||||
hasTgBotToken: boolean;
|
||||
hasTwoFactorToken: boolean;
|
||||
hasWarpSecret: boolean;
|
||||
ipLimitAllowlist: string;
|
||||
ldapAutoCreate: boolean;
|
||||
ldapAutoDelete: boolean;
|
||||
ldapBaseDN: string;
|
||||
ldapBindDN: string;
|
||||
ldapDefaultExpiryDays: number;
|
||||
ldapDefaultLimitIP: number;
|
||||
ldapDefaultTotalGB: number;
|
||||
ldapEnable: boolean;
|
||||
ldapFlagField: string;
|
||||
ldapHost: string;
|
||||
ldapInboundTags: string;
|
||||
ldapInsecureSkipVerify: boolean;
|
||||
ldapInvertFlag: boolean;
|
||||
ldapPassword: string;
|
||||
ldapPort: number;
|
||||
ldapSyncCron: string;
|
||||
ldapTruthyValues: string;
|
||||
ldapUseTLS: boolean;
|
||||
ldapUserAttr: string;
|
||||
ldapUserFilter: string;
|
||||
ldapVlessField: string;
|
||||
outboundDownThreshold: number;
|
||||
pageSize: number;
|
||||
panelOutbound: string;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
smtpCpu: number;
|
||||
smtpEnable: boolean;
|
||||
smtpEnabledEvents: string;
|
||||
smtpEncryptionType: string;
|
||||
smtpFrom: string;
|
||||
smtpFromName: string;
|
||||
smtpHost: string;
|
||||
smtpMemory: number;
|
||||
smtpPassword: string;
|
||||
smtpPort: number;
|
||||
smtpTo: string;
|
||||
smtpUsername: string;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashAutoDetect: boolean;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subClashUserAgentRegex: string;
|
||||
subDomain: string;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonEnable: boolean;
|
||||
@@ -191,7 +105,6 @@ export interface AllSettingView {
|
||||
subJsonMux: string;
|
||||
subJsonObservatory: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
subJsonUserAgentRegex: string;
|
||||
subKeyFile: string;
|
||||
@@ -199,7 +112,6 @@ export interface AllSettingView {
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
@@ -220,8 +132,6 @@ export interface AllSettingView {
|
||||
timeLocation: string;
|
||||
trafficDiff: number;
|
||||
trustedProxyCIDRs: string;
|
||||
twoFactorEnable: boolean;
|
||||
twoFactorToken: string;
|
||||
warpUpdateInterval: number;
|
||||
webBasePath: string;
|
||||
webCertFile: string;
|
||||
@@ -231,12 +141,6 @@ export interface AllSettingView {
|
||||
webPort: number;
|
||||
}
|
||||
|
||||
export interface AmneziaWGLogs {
|
||||
events: string[];
|
||||
peers: PeerActivity[];
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
createdAt: number;
|
||||
enabled: boolean;
|
||||
@@ -481,6 +385,7 @@ export interface Inbound {
|
||||
enable: boolean;
|
||||
expiryTime: number;
|
||||
fallbackParent?: FallbackParentInfo | null;
|
||||
forwarding: string;
|
||||
id: number;
|
||||
lastTrafficResetTime: number;
|
||||
listen: string;
|
||||
@@ -601,6 +506,7 @@ export interface NodeMutationRequest {
|
||||
inboundSyncMode: string;
|
||||
inboundTags: string[];
|
||||
name: string;
|
||||
nodeType?: string;
|
||||
outboundTag: string;
|
||||
pinnedCertSha256: string;
|
||||
port: number;
|
||||
@@ -635,6 +541,7 @@ export interface NodeView {
|
||||
name: string;
|
||||
netDown: number;
|
||||
netUp: number;
|
||||
nodeType?: string;
|
||||
onlineCount: number;
|
||||
outboundTag: string;
|
||||
panelVersion: string;
|
||||
@@ -668,19 +575,6 @@ export interface PanelUpdateStatus {
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface PeerActivity {
|
||||
allowedIPs: string;
|
||||
down: number;
|
||||
email: string;
|
||||
endpoint: string;
|
||||
handshake: number;
|
||||
inboundId: number;
|
||||
interface: string;
|
||||
online: boolean;
|
||||
tag: string;
|
||||
up: number;
|
||||
}
|
||||
|
||||
export interface ProbeResultUI {
|
||||
cpuPct: number;
|
||||
error: string;
|
||||
|
||||
@@ -15,6 +15,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||
export const SubLinkProviderSchema = z.unknown();
|
||||
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
||||
|
||||
export const rawBytesSchema = z.array(z.number().int());
|
||||
export type rawBytes = z.infer<typeof rawBytesSchema>;
|
||||
|
||||
export const staticEgressResolverSchema = z.string();
|
||||
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||
|
||||
@@ -27,64 +30,24 @@ export type transportBits = z.infer<typeof transportBitsSchema>;
|
||||
export const AllSettingSchema = z.object({
|
||||
datepicker: z.string(),
|
||||
expireDiff: z.number().int().min(0),
|
||||
externalTrafficInformEnable: z.boolean(),
|
||||
externalTrafficInformURI: z.string(),
|
||||
ipLimitAllowlist: z.string(),
|
||||
ldapAutoCreate: z.boolean(),
|
||||
ldapAutoDelete: z.boolean(),
|
||||
ldapBaseDN: z.string(),
|
||||
ldapBindDN: z.string(),
|
||||
ldapDefaultExpiryDays: z.number().int().min(0),
|
||||
ldapDefaultLimitIP: z.number().int().min(0),
|
||||
ldapDefaultTotalGB: z.number().int().min(0),
|
||||
ldapEnable: z.boolean(),
|
||||
ldapFlagField: z.string(),
|
||||
ldapHost: z.string(),
|
||||
ldapInboundTags: z.string(),
|
||||
ldapInsecureSkipVerify: z.boolean(),
|
||||
ldapInvertFlag: z.boolean(),
|
||||
ldapPassword: z.string(),
|
||||
ldapPort: z.number().int().min(0).max(65535),
|
||||
ldapSyncCron: z.string(),
|
||||
ldapTruthyValues: z.string(),
|
||||
ldapUseTLS: z.boolean(),
|
||||
ldapUserAttr: z.string(),
|
||||
ldapUserFilter: z.string(),
|
||||
ldapVlessField: z.string(),
|
||||
outboundDownThreshold: z.number().int().min(1).max(100),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
panelOutbound: z.string(),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
smtpCpu: z.number().int().min(0).max(100),
|
||||
smtpEnable: z.boolean(),
|
||||
smtpEnabledEvents: z.string(),
|
||||
smtpEncryptionType: z.string(),
|
||||
smtpFrom: z.string(),
|
||||
smtpFromName: z.string(),
|
||||
smtpHost: z.string(),
|
||||
smtpMemory: z.number().int().min(0).max(100),
|
||||
smtpPassword: z.string(),
|
||||
smtpPort: z.number().int().min(1).max(65535),
|
||||
smtpTo: z.string(),
|
||||
smtpUsername: z.string(),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashAutoDetect: z.boolean(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subClashUserAgentRegex: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
@@ -92,15 +55,13 @@ export const AllSettingSchema = z.object({
|
||||
subJsonMux: z.string(),
|
||||
subJsonObservatory: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
subJsonUserAgentRegex: z.string(),
|
||||
subKeyFile: z.string(),
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subPort: z.number().int().min(0).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
@@ -121,8 +82,6 @@ export const AllSettingSchema = z.object({
|
||||
timeLocation: z.string(),
|
||||
trafficDiff: z.number().int().min(0).max(100),
|
||||
trustedProxyCIDRs: z.string(),
|
||||
twoFactorEnable: z.boolean(),
|
||||
twoFactorToken: z.string(),
|
||||
warpUpdateInterval: z.number().int().min(0),
|
||||
webBasePath: z.string(),
|
||||
webCertFile: z.string(),
|
||||
@@ -136,71 +95,28 @@ export type AllSetting = z.infer<typeof AllSettingSchema>;
|
||||
export const AllSettingViewSchema = z.object({
|
||||
datepicker: z.string(),
|
||||
expireDiff: z.number().int().min(0),
|
||||
externalTrafficInformEnable: z.boolean(),
|
||||
externalTrafficInformURI: z.string(),
|
||||
hasApiToken: z.boolean(),
|
||||
hasLdapPassword: z.boolean(),
|
||||
hasNordSecret: z.boolean(),
|
||||
hasSmtpPassword: z.boolean(),
|
||||
hasTgBotToken: z.boolean(),
|
||||
hasTwoFactorToken: z.boolean(),
|
||||
hasWarpSecret: z.boolean(),
|
||||
ipLimitAllowlist: z.string(),
|
||||
ldapAutoCreate: z.boolean(),
|
||||
ldapAutoDelete: z.boolean(),
|
||||
ldapBaseDN: z.string(),
|
||||
ldapBindDN: z.string(),
|
||||
ldapDefaultExpiryDays: z.number().int().min(0),
|
||||
ldapDefaultLimitIP: z.number().int().min(0),
|
||||
ldapDefaultTotalGB: z.number().int().min(0),
|
||||
ldapEnable: z.boolean(),
|
||||
ldapFlagField: z.string(),
|
||||
ldapHost: z.string(),
|
||||
ldapInboundTags: z.string(),
|
||||
ldapInsecureSkipVerify: z.boolean(),
|
||||
ldapInvertFlag: z.boolean(),
|
||||
ldapPassword: z.string(),
|
||||
ldapPort: z.number().int().min(0).max(65535),
|
||||
ldapSyncCron: z.string(),
|
||||
ldapTruthyValues: z.string(),
|
||||
ldapUseTLS: z.boolean(),
|
||||
ldapUserAttr: z.string(),
|
||||
ldapUserFilter: z.string(),
|
||||
ldapVlessField: z.string(),
|
||||
outboundDownThreshold: z.number().int().min(1).max(100),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
panelOutbound: z.string(),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
smtpCpu: z.number().int().min(0).max(100),
|
||||
smtpEnable: z.boolean(),
|
||||
smtpEnabledEvents: z.string(),
|
||||
smtpEncryptionType: z.string(),
|
||||
smtpFrom: z.string(),
|
||||
smtpFromName: z.string(),
|
||||
smtpHost: z.string(),
|
||||
smtpMemory: z.number().int().min(0).max(100),
|
||||
smtpPassword: z.string(),
|
||||
smtpPort: z.number().int().min(1).max(65535),
|
||||
smtpTo: z.string(),
|
||||
smtpUsername: z.string(),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashAutoDetect: z.boolean(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subClashUserAgentRegex: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
@@ -208,15 +124,13 @@ export const AllSettingViewSchema = z.object({
|
||||
subJsonMux: z.string(),
|
||||
subJsonObservatory: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
subJsonUserAgentRegex: z.string(),
|
||||
subKeyFile: z.string(),
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subPort: z.number().int().min(0).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
@@ -237,8 +151,6 @@ export const AllSettingViewSchema = z.object({
|
||||
timeLocation: z.string(),
|
||||
trafficDiff: z.number().int().min(0).max(100),
|
||||
trustedProxyCIDRs: z.string(),
|
||||
twoFactorEnable: z.boolean(),
|
||||
twoFactorToken: z.string(),
|
||||
warpUpdateInterval: z.number().int().min(0),
|
||||
webBasePath: z.string(),
|
||||
webCertFile: z.string(),
|
||||
@@ -249,13 +161,6 @@ export const AllSettingViewSchema = z.object({
|
||||
});
|
||||
export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
|
||||
|
||||
export const AmneziaWGLogsSchema = z.object({
|
||||
events: z.array(z.string()),
|
||||
peers: z.array(z.lazy(() => PeerActivitySchema)),
|
||||
running: z.boolean(),
|
||||
});
|
||||
export type AmneziaWGLogs = z.infer<typeof AmneziaWGLogsSchema>;
|
||||
|
||||
export const ApiTokenSchema = z.object({
|
||||
createdAt: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
@@ -517,6 +422,7 @@ export const InboundSchema = z.object({
|
||||
enable: z.boolean(),
|
||||
expiryTime: z.number().int(),
|
||||
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
|
||||
forwarding: z.string(),
|
||||
id: z.number().int(),
|
||||
lastTrafficResetTime: z.number().int(),
|
||||
listen: z.string(),
|
||||
@@ -643,9 +549,10 @@ export const NodeMutationRequestSchema = z.object({
|
||||
inboundSyncMode: z.enum(['all', 'selected']),
|
||||
inboundTags: z.array(z.string()),
|
||||
name: z.string(),
|
||||
nodeType: z.enum(['traditional', 'agent']).optional(),
|
||||
outboundTag: z.string(),
|
||||
pinnedCertSha256: z.string(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
remark: z.string(),
|
||||
scheme: z.enum(['http', 'https']),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||
@@ -678,6 +585,7 @@ export const NodeViewSchema = z.object({
|
||||
name: z.string(),
|
||||
netDown: z.number().int(),
|
||||
netUp: z.number().int(),
|
||||
nodeType: z.string().optional(),
|
||||
onlineCount: z.number().int(),
|
||||
outboundTag: z.string(),
|
||||
panelVersion: z.string(),
|
||||
@@ -714,20 +622,6 @@ export const PanelUpdateStatusSchema = z.object({
|
||||
});
|
||||
export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
|
||||
|
||||
export const PeerActivitySchema = z.object({
|
||||
allowedIPs: z.string(),
|
||||
down: z.number().int(),
|
||||
email: z.string(),
|
||||
endpoint: z.string(),
|
||||
handshake: z.number().int(),
|
||||
inboundId: z.number().int(),
|
||||
interface: z.string(),
|
||||
online: z.boolean(),
|
||||
tag: z.string(),
|
||||
up: z.number().int(),
|
||||
});
|
||||
export type PeerActivity = z.infer<typeof PeerActivitySchema>;
|
||||
|
||||
export const ProbeResultUISchema = z.object({
|
||||
cpuPct: z.number(),
|
||||
error: z.string(),
|
||||
|
||||
@@ -162,7 +162,7 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
|
||||
}
|
||||
|
||||
export interface UseClientsOptions {
|
||||
// Callers that only need the mutations — the bulk modals, the groups page —
|
||||
// Callers that only need the mutations — the bulk modals —
|
||||
// pass false. Mounting them used to start a second 5-second poll of the paged
|
||||
// list whose result they never read, which on a large panel means a full
|
||||
// summary aggregate every 5 seconds for nothing.
|
||||
@@ -328,22 +328,6 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
},
|
||||
});
|
||||
|
||||
const bulkAddToGroupMut = useMutation({
|
||||
mutationFn: (body: { emails: string[]; group: string }) =>
|
||||
HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const bulkRemoveFromGroupMut = useMutation({
|
||||
mutationFn: (body: { emails: string[] }) =>
|
||||
HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
|
||||
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
|
||||
@@ -583,20 +567,6 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
},
|
||||
[bulkSetEnableMut],
|
||||
);
|
||||
const bulkAddToGroup = useCallback(
|
||||
(emails: string[], group: string) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||
return bulkAddToGroupMut.mutateAsync({ emails, group });
|
||||
},
|
||||
[bulkAddToGroupMut],
|
||||
);
|
||||
const bulkRemoveFromGroup = useCallback(
|
||||
(emails: string[]) => {
|
||||
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
|
||||
return bulkRemoveFromGroupMut.mutateAsync({ emails });
|
||||
},
|
||||
[bulkRemoveFromGroupMut],
|
||||
);
|
||||
const attach = useCallback(
|
||||
(email: string, inboundIds: number[]) => {
|
||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||
@@ -807,8 +777,6 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
bulkAdjust,
|
||||
bulkEnable,
|
||||
bulkDisable,
|
||||
bulkAddToGroup,
|
||||
bulkRemoveFromGroup,
|
||||
attach,
|
||||
setExternalLinks,
|
||||
bulkAttach,
|
||||
|
||||
@@ -6,14 +6,12 @@ const TITLE_KEYS: Record<string, string> = {
|
||||
'/': 'menu.dashboard',
|
||||
'/inbounds': 'menu.inbounds',
|
||||
'/clients': 'menu.clients',
|
||||
'/groups': 'menu.groups',
|
||||
'/nodes': 'menu.nodes',
|
||||
'/hosts': 'menu.hosts',
|
||||
'/settings': 'menu.settings',
|
||||
'/xray': 'menu.xray',
|
||||
'/outbound': 'menu.outbounds',
|
||||
'/routing': 'menu.routing',
|
||||
'/api-docs': 'menu.apiDocs',
|
||||
};
|
||||
|
||||
export function usePageTitle() {
|
||||
@@ -22,7 +20,7 @@ export function usePageTitle() {
|
||||
|
||||
useEffect(() => {
|
||||
const key = TITLE_KEYS[pathname];
|
||||
const title = key ? t(key) : '3X-UI';
|
||||
const title = key ? t(key) : 'L-UI';
|
||||
const host = window.location.hostname;
|
||||
document.title = host ? `${host} - ${title}` : title;
|
||||
}, [pathname, t]);
|
||||
|
||||
@@ -7,46 +7,15 @@ import { parseMsg } from '@/utils/zodValidate';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import {
|
||||
OutboundTrafficListSchema,
|
||||
OutboundTestResultListSchema,
|
||||
XrayConfigPayloadSchema,
|
||||
XraySettingsValueSchema,
|
||||
type OutboundTestResult,
|
||||
type OutboundTrafficRow,
|
||||
} from '@/schemas/xray';
|
||||
|
||||
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||
// One HTTP-mode batch request tests this many outbounds through a single
|
||||
// shared temp xray instance; chunking keeps responses bounded (~30s worst
|
||||
// case — each probe is a cold plus a warm request) and lands Test All
|
||||
// results progressively.
|
||||
const HTTP_BATCH_CHUNK = 16;
|
||||
|
||||
function normalizeOutboundTestUrl(url: string) {
|
||||
return url || DEFAULT_TEST_URL;
|
||||
}
|
||||
|
||||
export function isUdpOutbound(outbound: unknown): boolean {
|
||||
const o = outbound as
|
||||
| { protocol?: string; streamSettings?: { network?: string } }
|
||||
| null
|
||||
| undefined;
|
||||
const p = o?.protocol;
|
||||
const n = o?.streamSettings?.network;
|
||||
return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
|
||||
}
|
||||
|
||||
export type OutboundTestMode = 'tcp' | 'http' | 'real';
|
||||
|
||||
export type { OutboundTrafficRow, OutboundTestResult };
|
||||
export type { OutboundTrafficRow };
|
||||
|
||||
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
|
||||
|
||||
export interface OutboundTestState {
|
||||
testing?: boolean;
|
||||
result?: OutboundTestResult | null;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export type SetTemplate = (
|
||||
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
|
||||
) => void;
|
||||
@@ -60,30 +29,14 @@ export interface UseXraySettingResult {
|
||||
setXraySetting: (next: string) => void;
|
||||
templateSettings: XraySettingsValue | null;
|
||||
setTemplateSettings: SetTemplate;
|
||||
outboundTestUrl: string;
|
||||
setOutboundTestUrl: (v: string) => void;
|
||||
inboundTags: string[];
|
||||
clientReverseTags: string[];
|
||||
subscriptionOutbounds: unknown[];
|
||||
subscriptionOutboundTags: string[];
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
subscriptionTestStates: Record<string, OutboundTestState>;
|
||||
testingAll: boolean;
|
||||
fetchAll: () => Promise<void>;
|
||||
fetchOutboundsTraffic: () => Promise<void>;
|
||||
resetOutboundsTraffic: (tag: string) => Promise<void>;
|
||||
testOutbound: (
|
||||
index: number,
|
||||
outbound: unknown,
|
||||
mode?: string,
|
||||
) => Promise<OutboundTestResult | null>;
|
||||
testSubscriptionOutbound: (
|
||||
tag: string,
|
||||
outbound: unknown,
|
||||
mode?: string,
|
||||
) => Promise<OutboundTestResult | null>;
|
||||
testAllOutbounds: (mode?: string) => Promise<void>;
|
||||
saveAll: () => Promise<void>;
|
||||
resetToDefault: () => Promise<void>;
|
||||
}
|
||||
@@ -136,9 +89,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
|
||||
const [xraySetting, setXraySettingState] = useState('');
|
||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
||||
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
||||
const [savedXraySetting, setSavedXraySetting] = useState('');
|
||||
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
|
||||
const config = configQuery.data;
|
||||
const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
|
||||
const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
|
||||
@@ -147,21 +98,10 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
[config],
|
||||
);
|
||||
const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [config]);
|
||||
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>(
|
||||
{},
|
||||
);
|
||||
// Subscription outbounds aren't in templateSettings.outbounds, so their test
|
||||
// results are keyed by tag rather than by index.
|
||||
const [subscriptionTestStates, setSubscriptionTestStates] = useState<
|
||||
Record<string, OutboundTestState>
|
||||
>({});
|
||||
const [testingAll, setTestingAll] = useState(false);
|
||||
|
||||
const syncingRef = useRef(false);
|
||||
const xraySettingRef = useRef('');
|
||||
const outboundTestUrlRef = useRef(outboundTestUrl);
|
||||
const savedXraySettingRef = useRef(savedXraySetting);
|
||||
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
|
||||
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
||||
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
||||
|
||||
@@ -169,9 +109,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
|
||||
useEffect(() => {
|
||||
xraySettingRef.current = xraySetting;
|
||||
outboundTestUrlRef.current = outboundTestUrl;
|
||||
savedXraySettingRef.current = savedXraySetting;
|
||||
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
|
||||
templateSettingsRef.current = templateSettings;
|
||||
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
||||
});
|
||||
@@ -180,17 +118,12 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
// of the previous config after a refetch. Local edits win over the refetch.
|
||||
if (config && config !== syncedConfig) {
|
||||
setSyncedConfig(config);
|
||||
const isDirty =
|
||||
savedXraySetting !== xraySetting ||
|
||||
savedOutboundTestUrl !== normalizeOutboundTestUrl(outboundTestUrl);
|
||||
const isDirty = savedXraySetting !== xraySetting;
|
||||
if (!isDirty) {
|
||||
const pretty = JSON.stringify(config.xraySetting, null, 2);
|
||||
const nextUrl = normalizeOutboundTestUrl(config.outboundTestUrl || '');
|
||||
setXraySettingState(pretty);
|
||||
setTemplateSettingsState(config.xraySetting);
|
||||
setSavedXraySetting(pretty);
|
||||
setOutboundTestUrlState(nextUrl);
|
||||
setSavedOutboundTestUrl(nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,10 +159,6 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setOutboundTestUrl = useCallback((v: string) => {
|
||||
setOutboundTestUrlState(v);
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||
}, [queryClient]);
|
||||
@@ -241,17 +170,14 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const sentXraySetting = xraySettingRef.current;
|
||||
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
||||
xraySetting: sentXraySetting,
|
||||
outboundTestUrl: sentTestUrl,
|
||||
});
|
||||
return { msg, sentXraySetting, sentTestUrl };
|
||||
return { msg, sentXraySetting };
|
||||
},
|
||||
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
|
||||
onSuccess: ({ msg, sentXraySetting }) => {
|
||||
if (!msg?.success) return;
|
||||
setSavedXraySetting(sentXraySetting);
|
||||
setSavedOutboundTestUrl(sentTestUrl);
|
||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||
},
|
||||
});
|
||||
@@ -291,193 +217,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
|
||||
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
||||
|
||||
// Shared POST + parse for a batch of outbound tests. The backend probes the
|
||||
// whole batch through one shared temp xray instance and returns results in
|
||||
// request order; this aligns them by index and shapes failures so every
|
||||
// input gets an OutboundTestResult.
|
||||
const postOutboundTestBatch = useCallback(
|
||||
async (outbounds: unknown[], effMode: string): Promise<OutboundTestResult[]> => {
|
||||
const failAll = (error: string): OutboundTestResult[] =>
|
||||
outbounds.map(() => ({ success: false, error, mode: effMode }));
|
||||
try {
|
||||
const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', {
|
||||
outbounds: JSON.stringify(outbounds),
|
||||
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
||||
mode: effMode,
|
||||
});
|
||||
const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
|
||||
if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
|
||||
const list = msg.obj;
|
||||
return outbounds.map(
|
||||
(_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode },
|
||||
);
|
||||
} catch (e) {
|
||||
return failAll(String(e));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const testOutbound = useCallback(
|
||||
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||
if (!outbound) return null;
|
||||
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: { testing: true, result: null, mode: effMode },
|
||||
}));
|
||||
const [result] = await postOutboundTestBatch([outbound], effMode);
|
||||
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
|
||||
return result.success ? result : null;
|
||||
},
|
||||
[postOutboundTestBatch],
|
||||
);
|
||||
|
||||
// Test a subscription outbound (not present in templateSettings.outbounds);
|
||||
// results are keyed by tag in subscriptionTestStates.
|
||||
const testSubscriptionOutbound = useCallback(
|
||||
async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||
if (!outbound || !tag) return null;
|
||||
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
|
||||
setSubscriptionTestStates((prev) => ({
|
||||
...prev,
|
||||
[tag]: { testing: true, result: null, mode: effMode },
|
||||
}));
|
||||
const [result] = await postOutboundTestBatch([outbound], effMode);
|
||||
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
|
||||
return result.success ? result : null;
|
||||
},
|
||||
[postOutboundTestBatch],
|
||||
);
|
||||
|
||||
const testAllOutbounds = useCallback(
|
||||
async (mode = 'tcp') => {
|
||||
// Template outbounds key their results by index (outboundTestStates);
|
||||
// subscription outbounds aren't in the template, so they key by tag
|
||||
// (subscriptionTestStates). Both go through the same probe endpoint.
|
||||
const templateList = templateSettingsRef.current?.outbounds || [];
|
||||
const subList = (subscriptionOutboundsRef.current || []) as Array<{
|
||||
tag?: string;
|
||||
protocol?: string;
|
||||
}>;
|
||||
if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
|
||||
setTestingAll(true);
|
||||
try {
|
||||
type TcpEntry =
|
||||
| { kind: 'tpl'; index: number; outbound: unknown }
|
||||
| { kind: 'sub'; tag: string; outbound: unknown };
|
||||
const tcpQueue: TcpEntry[] = [];
|
||||
// HTTP batches stay homogeneous (all template or all subscription) so a
|
||||
// tag shared between a template and a subscription outbound can't collide
|
||||
// inside one batch, and each batch's results route to one state map.
|
||||
const probeMode = mode === 'real' ? 'real' : 'http';
|
||||
const httpTplQueue: { index: number; outbound: unknown }[] = [];
|
||||
const httpSubQueue: { tag: string; outbound: unknown }[] = [];
|
||||
const enqueue = (
|
||||
ob: { tag?: string; protocol?: string },
|
||||
kind: 'tpl' | 'sub',
|
||||
index: number,
|
||||
tag: string,
|
||||
) => {
|
||||
const proto = ob?.protocol;
|
||||
if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
|
||||
// freedom ("direct") and dns aren't proxies — skip them in every mode.
|
||||
if (proto === 'freedom' || proto === 'dns') return;
|
||||
if (kind === 'sub' && !tag) return;
|
||||
const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
|
||||
if (kind === 'tpl') {
|
||||
if (toHttp) httpTplQueue.push({ index, outbound: ob });
|
||||
else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
|
||||
} else if (toHttp) {
|
||||
httpSubQueue.push({ tag, outbound: ob });
|
||||
} else {
|
||||
tcpQueue.push({ kind: 'sub', tag, outbound: ob });
|
||||
}
|
||||
};
|
||||
templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
|
||||
subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
|
||||
|
||||
// TCP probes are dial-only and cheap server-side; per-item requests
|
||||
// keep results landing one by one, each routed to its own state map.
|
||||
const runTcpLane = async () => {
|
||||
const queue = [...tcpQueue];
|
||||
const worker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) break;
|
||||
if (item.kind === 'sub')
|
||||
await testSubscriptionOutbound(item.tag, item.outbound, mode);
|
||||
else await testOutbound(item.index, item.outbound, mode);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
|
||||
};
|
||||
// HTTP probes go out as chunked batches — one temp xray spawn per
|
||||
// chunk instead of one per outbound, with results landing per chunk.
|
||||
const runTplHttpLane = async () => {
|
||||
for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of chunk)
|
||||
next[item.index] = { testing: true, result: null, mode: probeMode };
|
||||
return next;
|
||||
});
|
||||
const results = await postOutboundTestBatch(
|
||||
chunk.map((c) => c.outbound),
|
||||
probeMode,
|
||||
);
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
chunk.forEach((item, i) => {
|
||||
next[item.index] = { testing: false, result: results[i] };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
const runSubHttpLane = async () => {
|
||||
for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||
const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||
setSubscriptionTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of chunk)
|
||||
next[item.tag] = { testing: true, result: null, mode: probeMode };
|
||||
return next;
|
||||
});
|
||||
const results = await postOutboundTestBatch(
|
||||
chunk.map((c) => c.outbound),
|
||||
probeMode,
|
||||
);
|
||||
setSubscriptionTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
chunk.forEach((item, i) => {
|
||||
next[item.tag] = { testing: false, result: results[i] };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
// HTTP batches must not overlap: the backend serialises them with a
|
||||
// non-blocking lock and rejects a second concurrent batch ("Another
|
||||
// outbound test is already running"). Run the template and subscription
|
||||
// HTTP lanes one after the other; TCP probes don't take that lock, so
|
||||
// they still run alongside.
|
||||
const runHttpLane = async () => {
|
||||
await runTplHttpLane();
|
||||
await runSubHttpLane();
|
||||
};
|
||||
await Promise.all([runTcpLane(), runHttpLane()]);
|
||||
} finally {
|
||||
setTestingAll(false);
|
||||
}
|
||||
},
|
||||
[testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch],
|
||||
);
|
||||
|
||||
const saveDisabled =
|
||||
savedXraySetting === xraySetting &&
|
||||
savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
|
||||
const saveDisabled = savedXraySetting === xraySetting;
|
||||
|
||||
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
||||
|
||||
@@ -491,22 +231,14 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
setXraySetting,
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
setOutboundTestUrl,
|
||||
inboundTags,
|
||||
clientReverseTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
subscriptionTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
|
||||
resetOutboundsTraffic,
|
||||
testOutbound,
|
||||
testSubscriptionOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
}),
|
||||
@@ -519,22 +251,14 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
setXraySetting,
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
setOutboundTestUrl,
|
||||
inboundTags,
|
||||
clientReverseTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
subscriptionTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTrafficCb,
|
||||
resetOutboundsTraffic,
|
||||
testOutbound,
|
||||
testSubscriptionOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
],
|
||||
|
||||
@@ -12,14 +12,12 @@ import {
|
||||
ClusterOutlined,
|
||||
CodeOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
ExportOutlined,
|
||||
GithubOutlined,
|
||||
GlobalOutlined,
|
||||
HeartOutlined,
|
||||
ImportOutlined,
|
||||
LogoutOutlined,
|
||||
MailOutlined,
|
||||
MenuOutlined,
|
||||
MessageOutlined,
|
||||
MoonFilled,
|
||||
@@ -217,14 +215,12 @@ export default function AppSidebar() {
|
||||
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
|
||||
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
||||
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
|
||||
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
|
||||
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
|
||||
{ key: '/routing', icon: 'routing', title: t('menu.routing') },
|
||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||
],
|
||||
[t],
|
||||
@@ -250,7 +246,6 @@ export default function AppSidebar() {
|
||||
icon: <MessageOutlined />,
|
||||
label: t('pages.settings.TGBotSettings'),
|
||||
},
|
||||
{ key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
|
||||
{
|
||||
key: '/settings#subscription',
|
||||
icon: <CloudServerOutlined />,
|
||||
@@ -275,12 +270,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],
|
||||
);
|
||||
|
||||
@@ -366,7 +356,7 @@ export default function AppSidebar() {
|
||||
>
|
||||
<div className="sider-brand">
|
||||
<div className="brand-block">
|
||||
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
|
||||
<span className="brand-text">{railCollapsed ? 'L' : 'L-UI'}</span>
|
||||
</div>
|
||||
{!railCollapsed && (
|
||||
<div className="brand-actions">
|
||||
@@ -430,7 +420,7 @@ export default function AppSidebar() {
|
||||
>
|
||||
<div className="drawer-header">
|
||||
<div className="brand-block">
|
||||
<span className="drawer-brand">3X-UI</span>
|
||||
<span className="drawer-brand">L-UI</span>
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||
|
||||
@@ -22,6 +22,9 @@ function parseVersionParts(version: string): [number, number, number] | null {
|
||||
export function formatPanelVersion(version: string | undefined | null): string {
|
||||
const v = (version || '').trim();
|
||||
if (!v) return '';
|
||||
// This fork's display versions (master "V1.0.0", agent "A1.0.0") are
|
||||
// shown exactly as written — never re-prefixed or re-cased.
|
||||
if (/^[VA]\d+\.\d+\.\d+$/.test(v)) return v;
|
||||
const normalized = v.replace(/^v/i, '');
|
||||
return /^\d/.test(normalized) ? `v${normalized}` : v;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type {
|
||||
InboundFormValues,
|
||||
ShareAddrStrategy,
|
||||
TrafficReset,
|
||||
import {
|
||||
ForwardingSpecSchema,
|
||||
type ForwardingSpec,
|
||||
type InboundFormValues,
|
||||
type ShareAddrStrategy,
|
||||
type TrafficReset,
|
||||
} from '@/schemas/forms/inbound-form';
|
||||
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
||||
import {
|
||||
@@ -49,6 +51,7 @@ export interface RawInboundRow {
|
||||
trafficResetDay?: number;
|
||||
lastTrafficResetTime?: number;
|
||||
nodeId?: number | null;
|
||||
forwarding?: unknown;
|
||||
shareAddrStrategy?: string;
|
||||
shareAddr?: string;
|
||||
subSortIndex?: number;
|
||||
@@ -77,7 +80,8 @@ export interface WireInboundPayload {
|
||||
sniffing: string;
|
||||
tag: string;
|
||||
clientStats?: unknown;
|
||||
nodeId?: number;
|
||||
nodeId?: number | null;
|
||||
forwarding: string;
|
||||
shareAddrStrategy: ShareAddrStrategy;
|
||||
shareAddr: string;
|
||||
subSortIndex: number;
|
||||
@@ -111,6 +115,11 @@ function coerceTrafficReset(v: unknown): TrafficReset {
|
||||
: 'never';
|
||||
}
|
||||
|
||||
function coerceForwarding(v: unknown): ForwardingSpec {
|
||||
const parsed = ForwardingSpecSchema.safeParse(coerceJsonObject(v));
|
||||
return parsed.success ? parsed.data : ForwardingSpecSchema.parse({});
|
||||
}
|
||||
|
||||
function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
|
||||
return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
|
||||
? (v as ShareAddrStrategy)
|
||||
@@ -213,6 +222,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
|
||||
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
||||
nodeId: row.nodeId ?? null,
|
||||
forwarding: coerceForwarding(row.forwarding),
|
||||
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
||||
shareAddr: row.shareAddr ?? '',
|
||||
subSortIndex: Math.max(1, row.subSortIndex ?? 1),
|
||||
@@ -383,7 +393,16 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
||||
shareAddr: values.shareAddr,
|
||||
subSortIndex: values.subSortIndex,
|
||||
disableFlow: values.disableFlow,
|
||||
// Forwarding rides as a JSON string like settings; direct mode stores
|
||||
// an empty string so the row carries no forwarding residue.
|
||||
forwarding:
|
||||
values.forwarding && values.forwarding.mode === 'forward'
|
||||
? JSON.stringify(values.forwarding)
|
||||
: '',
|
||||
};
|
||||
if (values.nodeId != null) payload.nodeId = values.nodeId;
|
||||
// nodeId is always emitted (null = local panel): the update endpoint
|
||||
// distinguishes an explicit null — "move the inbound back to local" —
|
||||
// from an omitted key, which keeps the stored assignment untouched.
|
||||
payload.nodeId = values.nodeId ?? null;
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export type DBInboundInit = Partial<{
|
||||
streamSettings: RawJsonField;
|
||||
tag: string;
|
||||
sniffing: RawJsonField;
|
||||
forwarding: string;
|
||||
clientStats: ClientStats[];
|
||||
nodeId: number | null;
|
||||
shareAddrStrategy: string;
|
||||
@@ -88,6 +89,7 @@ export class DBInbound {
|
||||
streamSettings: RawJsonField;
|
||||
tag: string;
|
||||
sniffing: RawJsonField;
|
||||
forwarding: string;
|
||||
clientStats: ClientStats[];
|
||||
nodeId: number | null;
|
||||
shareAddrStrategy: string;
|
||||
@@ -119,6 +121,7 @@ export class DBInbound {
|
||||
this.streamSettings = '';
|
||||
this.tag = '';
|
||||
this.sniffing = '';
|
||||
this.forwarding = '';
|
||||
this.clientStats = [];
|
||||
this.nodeId = null;
|
||||
this.shareAddrStrategy = 'node';
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
.api-docs-page.is-dark {
|
||||
--sw-bg: #1f2026;
|
||||
--sw-bg-soft: #25272e;
|
||||
--sw-bg-input: #15161a;
|
||||
--sw-bg-code: #0d0e12;
|
||||
--sw-border: rgba(255, 255, 255, 0.08);
|
||||
--sw-border-strong: rgba(255, 255, 255, 0.15);
|
||||
--sw-text: rgba(255, 255, 255, 0.88);
|
||||
--sw-text-muted: rgba(255, 255, 255, 0.6);
|
||||
--sw-text-dim: rgba(255, 255, 255, 0.45);
|
||||
--sw-accent: #58a6ff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
--sw-bg: #0a0a0d;
|
||||
--sw-bg-soft: #131316;
|
||||
--sw-bg-input: #050507;
|
||||
--sw-bg-code: #000;
|
||||
--sw-border: rgba(255, 255, 255, 0.06);
|
||||
--sw-border-strong: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.api-docs-page .content-shell {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.api-docs-page .content-area {
|
||||
padding: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.api-docs-page .content-area {
|
||||
padding: 8px;
|
||||
padding-top: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-docs-page .docs-wrapper {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-docs-page .swagger-ui {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
Dark mode — Swagger UI does not ship a dark theme, so every visual
|
||||
surface needs an explicit override. Method-color chips (GET / POST /
|
||||
…) are left untouched because they carry meaning at a glance.
|
||||
────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui,
|
||||
.api-docs-page.is-dark .swagger-ui .info .title,
|
||||
.api-docs-page.is-dark .swagger-ui .info .title small pre,
|
||||
.api-docs-page.is-dark .swagger-ui .info p,
|
||||
.api-docs-page.is-dark .swagger-ui .info li,
|
||||
.api-docs-page.is-dark .swagger-ui .info table,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-tag,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path__deprecated,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-description,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-description-wrapper p,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-external-docs-wrapper p,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-title_normal p,
|
||||
.api-docs-page.is-dark .swagger-ui table thead tr td,
|
||||
.api-docs-page.is-dark .swagger-ui table thead tr th,
|
||||
.api-docs-page.is-dark .swagger-ui table tbody tr td,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__name,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__type,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__in,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__extension,
|
||||
.api-docs-page.is-dark .swagger-ui .response-col_status,
|
||||
.api-docs-page.is-dark .swagger-ui .response-col_description,
|
||||
.api-docs-page.is-dark .swagger-ui .response-col_links,
|
||||
.api-docs-page.is-dark .swagger-ui .responses-inner h4,
|
||||
.api-docs-page.is-dark .swagger-ui .responses-inner h5,
|
||||
.api-docs-page.is-dark .swagger-ui label,
|
||||
.api-docs-page.is-dark .swagger-ui .tab li,
|
||||
.api-docs-page.is-dark .swagger-ui .tab li button,
|
||||
.api-docs-page.is-dark .swagger-ui .markdown,
|
||||
.api-docs-page.is-dark .swagger-ui .markdown p,
|
||||
.api-docs-page.is-dark .swagger-ui .markdown li,
|
||||
.api-docs-page.is-dark .swagger-ui .renderedmarkdown p,
|
||||
.api-docs-page.is-dark .swagger-ui .renderedmarkdown li,
|
||||
.api-docs-page.is-dark .swagger-ui .model-title,
|
||||
.api-docs-page.is-dark .swagger-ui .model,
|
||||
.api-docs-page.is-dark .swagger-ui .model-toggle:after,
|
||||
.api-docs-page.is-dark .swagger-ui section.models h4,
|
||||
.api-docs-page.is-dark .swagger-ui section.models h5,
|
||||
.api-docs-page.is-dark .swagger-ui .auth-container h4,
|
||||
.api-docs-page.is-dark .swagger-ui .auth-container h6,
|
||||
.api-docs-page.is-dark .swagger-ui .scopes h2,
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header h3,
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content h4,
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content p,
|
||||
.api-docs-page.is-dark .swagger-ui .servers-title {
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-summary-description,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__in,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__type,
|
||||
.api-docs-page.is-dark .swagger-ui .parameter__extension,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-title_normal small,
|
||||
.api-docs-page.is-dark .swagger-ui .servers > label,
|
||||
.api-docs-page.is-dark .swagger-ui .servers > label span,
|
||||
.api-docs-page.is-dark .swagger-ui .response-control-media-type__accept-message {
|
||||
color: var(--sw-text-muted);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-tag {
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock {
|
||||
background: var(--sw-bg-soft);
|
||||
border-color: var(--sw-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
box-shadow: inset 0 -1px 0 var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header h4,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header label {
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary {
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-body pre.microlight,
|
||||
.api-docs-page.is-dark .swagger-ui .highlight-code,
|
||||
.api-docs-page.is-dark .swagger-ui .microlight,
|
||||
.api-docs-page.is-dark .swagger-ui pre.example,
|
||||
.api-docs-page.is-dark .swagger-ui code {
|
||||
background: var(--sw-bg-code);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .highlight-code .copy-to-clipboard {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui input[type='text'],
|
||||
.api-docs-page.is-dark .swagger-ui input[type='password'],
|
||||
.api-docs-page.is-dark .swagger-ui input[type='search'],
|
||||
.api-docs-page.is-dark .swagger-ui input[type='email'],
|
||||
.api-docs-page.is-dark .swagger-ui input[type='file'],
|
||||
.api-docs-page.is-dark .swagger-ui textarea {
|
||||
background: var(--sw-bg-input);
|
||||
color: var(--sw-text);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui select {
|
||||
background-color: var(--sw-bg-input);
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='%23b7bcbf' d='M13.418 7.859a.695.695 0 0 1 .978 0 .68.68 0 0 1 0 .969l-3.908 3.83a.697.697 0 0 1-.979 0l-3.908-3.83a.68.68 0 0 1 0-.969.695.695 0 0 1 .978 0L10 11z'/></svg>");
|
||||
background-position: right 10px center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 20px;
|
||||
color: var(--sw-text);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui select option {
|
||||
background-color: var(--sw-bg-input);
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui input::placeholder,
|
||||
.api-docs-page.is-dark .swagger-ui textarea::placeholder {
|
||||
color: var(--sw-text-dim);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .scheme-container {
|
||||
background: var(--sw-bg-soft);
|
||||
box-shadow: inset 0 -1px 0 var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize {
|
||||
color: var(--sw-text);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize svg {
|
||||
fill: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .model-box,
|
||||
.api-docs-page.is-dark .swagger-ui section.models {
|
||||
background: var(--sw-bg-soft);
|
||||
border-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui section.models.is-open h4 {
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui section.models .model-container {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui section.models .model-container:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .prop-type,
|
||||
.api-docs-page.is-dark .swagger-ui .prop-format {
|
||||
color: var(--sw-accent);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .property.primitive {
|
||||
color: var(--sw-text-muted);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-title_normal h4 {
|
||||
color: var(--sw-text);
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui table.parameters,
|
||||
.api-docs-page.is-dark .swagger-ui table.responses-table,
|
||||
.api-docs-page.is-dark .swagger-ui table thead tr {
|
||||
border-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui table tbody tr td {
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui table thead tr {
|
||||
border-bottom-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .response-col_status {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--sw-text);
|
||||
border-color: var(--sw-border-strong);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.execute {
|
||||
background: var(--sw-accent);
|
||||
color: #0d1117;
|
||||
border-color: var(--sw-accent);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.execute:hover {
|
||||
background: #79b9ff;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.authorize {
|
||||
color: #52c41a;
|
||||
border-color: rgba(82, 196, 26, 0.4);
|
||||
background: rgba(82, 196, 26, 0.08);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.authorize svg {
|
||||
fill: #52c41a;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .authorization__btn svg,
|
||||
.api-docs-page.is-dark .swagger-ui .expand-operation svg,
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-control-arrow svg {
|
||||
fill: var(--sw-text);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .authorization__btn .locked,
|
||||
.api-docs-page.is-dark .swagger-ui .authorization__btn .unlocked {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.cancel {
|
||||
color: #ff7875;
|
||||
border-color: rgba(255, 120, 117, 0.4);
|
||||
background: rgba(255, 120, 117, 0.08);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.btn-clear,
|
||||
.api-docs-page.is-dark .swagger-ui .btn-clear,
|
||||
.api-docs-page.is-dark .swagger-ui .try-out__btn {
|
||||
color: var(--sw-text);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .btn.btn-clear:hover,
|
||||
.api-docs-page.is-dark .swagger-ui .btn-clear:hover,
|
||||
.api-docs-page.is-dark .swagger-ui .try-out__btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .filter .operation-filter-input {
|
||||
background: var(--sw-bg-input);
|
||||
border-color: var(--sw-border-strong);
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux {
|
||||
background: var(--sw-bg);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header {
|
||||
border-bottom-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content {
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .arrow,
|
||||
.api-docs-page.is-dark .swagger-ui svg.arrow {
|
||||
fill: var(--sw-text-muted);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-summary-control:focus {
|
||||
outline-color: var(--sw-accent);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui a,
|
||||
.api-docs-page.is-dark .swagger-ui .info a,
|
||||
.api-docs-page.is-dark .swagger-ui .info hgroup.main a,
|
||||
.api-docs-page.is-dark .swagger-ui .info .base-url,
|
||||
.api-docs-page.is-dark .swagger-ui .info__contact a,
|
||||
.api-docs-page.is-dark .swagger-ui .info__license a,
|
||||
.api-docs-page.is-dark .swagger-ui .info__tos a {
|
||||
color: var(--sw-accent);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui a:hover {
|
||||
color: #79b9ff;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .info .title small {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .info .title small pre {
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .response-control-media-type--accept-controller select {
|
||||
border-color: rgba(82, 196, 26, 0.5);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .loading-container .loading:before {
|
||||
border-color: var(--sw-accent) transparent transparent;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .json-schema-form-item input,
|
||||
.api-docs-page.is-dark .swagger-ui .json-schema-form-item select {
|
||||
background: var(--sw-bg-input);
|
||||
color: var(--sw-text);
|
||||
border-color: var(--sw-border-strong);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .topbar {
|
||||
background: var(--sw-bg);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .information-container {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .opblock-summary-method {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .auth-btn-wrapper {
|
||||
border-top-color: var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .servers .computed-url,
|
||||
.api-docs-page.is-dark .swagger-ui .computed-url {
|
||||
background: var(--sw-bg-code);
|
||||
color: var(--sw-text);
|
||||
border: 1px solid var(--sw-border);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .computed-url code,
|
||||
.api-docs-page.is-dark .swagger-ui .servers .computed-url code {
|
||||
background: transparent;
|
||||
color: var(--sw-text);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .errors-wrapper {
|
||||
background: rgba(255, 77, 79, 0.08);
|
||||
border-color: rgba(255, 77, 79, 0.3);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors h4,
|
||||
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors small {
|
||||
color: var(--sw-text);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConfigProvider, Layout } from 'antd';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import 'swagger-ui-react/swagger-ui.css';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import './ApiDocsPage.css';
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const openApiUrl = `${basePath}panel/api/openapi.json`;
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['api-docs-page'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
if (isUltra) classes.push('is-ultra');
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar />
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
||||
<SwaggerUI
|
||||
url={openApiUrl}
|
||||
docExpansion="list"
|
||||
deepLinking={false}
|
||||
tryItOutEnabled
|
||||
persistAuthorization
|
||||
/>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,89 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutoComplete, Form, Modal, message } from 'antd';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
type GroupFormValues = { group: string };
|
||||
|
||||
const EMPTY: GroupFormValues = { group: '' };
|
||||
|
||||
interface BulkAddToGroupModalProps {
|
||||
open: boolean;
|
||||
count: number;
|
||||
groups: string[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (group: string) => Promise<{ affected?: number } | null>;
|
||||
}
|
||||
|
||||
export default function BulkAddToGroupModal({
|
||||
open,
|
||||
count,
|
||||
groups,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: BulkAddToGroupModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const methods = useForm<GroupFormValues>({ defaultValues: EMPTY });
|
||||
const group = useWatch({ control: methods.control, name: 'group' });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) methods.reset(EMPTY);
|
||||
}, [open, methods]);
|
||||
|
||||
async function submit() {
|
||||
const next = (methods.getValues().group ?? '').trim();
|
||||
if (!next) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await onSubmit(next);
|
||||
if (result) {
|
||||
const affected = result.affected ?? 0;
|
||||
messageApi.success(
|
||||
t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }),
|
||||
);
|
||||
onOpenChange(false);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('pages.clients.addToGroupTitle', { count })}
|
||||
okText={t('add')}
|
||||
cancelText={t('cancel')}
|
||||
confirmLoading={submitting}
|
||||
okButtonProps={{ disabled: !(group ?? '').trim() }}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onOk={submit}
|
||||
destroyOnHidden
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical">
|
||||
<FormField
|
||||
name="group"
|
||||
label={t('pages.clients.group')}
|
||||
tooltip={t('pages.clients.addToGroupTooltip')}
|
||||
transform={{ output: (v) => v ?? '' }}
|
||||
>
|
||||
<AutoComplete
|
||||
placeholder={t('pages.clients.groupName')}
|
||||
options={groups.map((g) => ({ value: g }))}
|
||||
allowClear
|
||||
autoFocus
|
||||
/>
|
||||
</FormField>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,8 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'hysteria',
|
||||
'wireguard',
|
||||
'amneziawg',
|
||||
'socks',
|
||||
'mixed',
|
||||
]);
|
||||
|
||||
const EMPTY: ClientBulkAddFormValues = {
|
||||
|
||||
@@ -61,6 +61,8 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'wireguard',
|
||||
'mtproto',
|
||||
'amneziawg',
|
||||
'socks',
|
||||
'mixed',
|
||||
]);
|
||||
|
||||
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
SearchOutlined,
|
||||
SortAscendingOutlined,
|
||||
StopOutlined,
|
||||
TagsOutlined,
|
||||
TeamOutlined,
|
||||
UploadOutlined,
|
||||
UsergroupAddOutlined,
|
||||
@@ -83,7 +82,6 @@ const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
|
||||
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
||||
const FilterDrawer = lazy(() => import('./FilterDrawer'));
|
||||
const SubLinksModal = lazy(() => import('./SubLinksModal'));
|
||||
const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
|
||||
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
|
||||
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
|
||||
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
||||
@@ -97,45 +95,6 @@ const FILTER_STATE_KEY = 'clientsFilterState';
|
||||
const DISABLED_PAGE_SIZE = 200;
|
||||
const DEFAULT_TABLE_PAGE_SIZE = 25;
|
||||
|
||||
function UngroupIcon() {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
}}
|
||||
>
|
||||
<TagsOutlined />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '125%',
|
||||
height: '1.5px',
|
||||
background: 'currentColor',
|
||||
transform: 'rotate(-45deg)',
|
||||
borderRadius: '1px',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// The server sends exact counters but caps the email arrays behind them, so a
|
||||
// panel with thousands of depleted clients neither ships nor renders them all.
|
||||
// The trailing chip reports what the popover left out.
|
||||
@@ -314,8 +273,6 @@ export default function ClientsPage() {
|
||||
bulkAdjust,
|
||||
bulkEnable,
|
||||
bulkDisable,
|
||||
bulkAddToGroup,
|
||||
bulkRemoveFromGroup,
|
||||
attach,
|
||||
setExternalLinks,
|
||||
bulkAttach,
|
||||
@@ -363,7 +320,6 @@ export default function ClientsPage() {
|
||||
const [bulkAddOpen, setBulkAddOpen] = useState(false);
|
||||
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
|
||||
const [subLinksOpen, setSubLinksOpen] = useState(false);
|
||||
const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
|
||||
const [bulkAttachOpen, setBulkAttachOpen] = useState(false);
|
||||
const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||
@@ -846,27 +802,6 @@ export default function ClientsPage() {
|
||||
});
|
||||
}
|
||||
|
||||
function onBulkUngroup() {
|
||||
const emails = [...selectedRowKeys];
|
||||
if (emails.length === 0) return;
|
||||
modal.confirm({
|
||||
title: t('pages.clients.ungroupConfirmTitle', { count: emails.length }),
|
||||
content: t('pages.clients.ungroupConfirmContent'),
|
||||
okText: t('confirm'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await bulkRemoveFromGroup(emails);
|
||||
if (msg?.success) {
|
||||
setSelectedRowKeys([]);
|
||||
const affected =
|
||||
(msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
|
||||
messageApi.success(t('pages.clients.ungroupSuccessToast', { count: affected }));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onBulkSetEnable(enable: boolean) {
|
||||
const emails = [...selectedRowKeys];
|
||||
if (emails.length === 0) return;
|
||||
@@ -1382,19 +1317,6 @@ export default function ClientsPage() {
|
||||
danger: true,
|
||||
onClick: () => setBulkDetachOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'addToGroup',
|
||||
icon: <TagsOutlined />,
|
||||
label: t('pages.clients.addToGroup'),
|
||||
onClick: () => setBulkGroupOpen(true),
|
||||
},
|
||||
{
|
||||
key: 'ungroup',
|
||||
icon: <UngroupIcon />,
|
||||
label: t('pages.clients.ungroup'),
|
||||
danger: true,
|
||||
onClick: onBulkUngroup,
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'enable',
|
||||
@@ -1912,22 +1834,6 @@ export default function ClientsPage() {
|
||||
onOpenChange={setSubLinksOpen}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={bulkGroupOpen}>
|
||||
<BulkAddToGroupModal
|
||||
open={bulkGroupOpen}
|
||||
count={selectedRowKeys.length}
|
||||
groups={allGroups}
|
||||
onOpenChange={setBulkGroupOpen}
|
||||
onSubmit={async (group) => {
|
||||
const msg = await bulkAddToGroup([...selectedRowKeys], group);
|
||||
if (msg?.success) {
|
||||
setSelectedRowKeys([]);
|
||||
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={bulkAttachOpen}>
|
||||
<BulkAttachInboundsModal
|
||||
open={bulkAttachOpen}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import type { ClientRecord } from '@/hooks/useClients';
|
||||
|
||||
interface GroupAddClientsModalProps {
|
||||
open: boolean;
|
||||
groupName: string | null;
|
||||
candidates: ClientRecord[];
|
||||
onClose: () => void;
|
||||
onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
|
||||
}
|
||||
|
||||
interface ClientRow {
|
||||
email: string;
|
||||
comment: string;
|
||||
enable: boolean;
|
||||
currentGroup: string;
|
||||
}
|
||||
|
||||
export default function GroupAddClientsModal({
|
||||
open,
|
||||
groupName,
|
||||
candidates,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: GroupAddClientsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const rows = useMemo<ClientRow[]>(
|
||||
() =>
|
||||
(candidates || [])
|
||||
.map((c) => ({
|
||||
email: (c.email || '').trim(),
|
||||
comment: (c.comment || '').trim(),
|
||||
enable: c.enable !== false,
|
||||
currentGroup: (c.group || '').trim(),
|
||||
}))
|
||||
.filter((r) => r.email),
|
||||
[candidates],
|
||||
);
|
||||
|
||||
// React resets this during render rather than in an effect so the modal's
|
||||
// first open frame already shows cleared fields.
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) {
|
||||
setSelectedEmails([]);
|
||||
setSearch('');
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
r.email.toLowerCase().includes(q) ||
|
||||
r.comment.toLowerCase().includes(q) ||
|
||||
r.currentGroup.toLowerCase().includes(q),
|
||||
);
|
||||
}, [rows, search]);
|
||||
|
||||
const columns: ColumnsType<ClientRow> = useMemo(
|
||||
() => [
|
||||
{ title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
|
||||
{ title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
|
||||
{
|
||||
title: t('pages.clients.group'),
|
||||
dataIndex: 'currentGroup',
|
||||
key: 'currentGroup',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (g: string) =>
|
||||
g ? (
|
||||
<Tag color="geekblue">{g}</Tag>
|
||||
) : (
|
||||
<span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('enable'),
|
||||
dataIndex: 'enable',
|
||||
key: 'enable',
|
||||
width: 90,
|
||||
render: (enabled: boolean) =>
|
||||
enabled ? (
|
||||
<Tag color="success">{t('enable')}</Tag>
|
||||
) : (
|
||||
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
if (!groupName || selectedEmails.length === 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await onSubmit(selectedEmails);
|
||||
if (!result) return;
|
||||
const affected = result.affected ?? selectedEmails.length;
|
||||
messageApi.success(t('pages.groups.addToGroupResult', { count: affected, name: groupName }));
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={submit}
|
||||
okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
|
||||
okText={t('add')}
|
||||
cancelText={t('cancel')}
|
||||
title={t('pages.groups.addToGroupTitle', { name: groupName ?? '' })}
|
||||
width={720}
|
||||
>
|
||||
{messageContextHolder}
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('pages.groups.addToGroupDesc')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.inbounds.attachClientsSelectedCount', {
|
||||
selected: selectedEmails.length,
|
||||
total: rows.length,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{rows.length === 0 ? (
|
||||
<Alert type="info" showIcon title={t('pages.groups.addToGroupEmpty')} />
|
||||
) : (
|
||||
<Table<ClientRow>
|
||||
size="small"
|
||||
rowKey="email"
|
||||
columns={columns}
|
||||
dataSource={filteredRows}
|
||||
pagination={false}
|
||||
scroll={{ y: 320 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedEmails,
|
||||
onChange: (keys) => setSelectedEmails(keys as string[]),
|
||||
preserveSelectedRowKeys: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import type { ClientRecord } from '@/hooks/useClients';
|
||||
|
||||
interface GroupRemoveClientsModalProps {
|
||||
open: boolean;
|
||||
groupName: string | null;
|
||||
members: ClientRecord[];
|
||||
onClose: () => void;
|
||||
onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
|
||||
}
|
||||
|
||||
interface ClientRow {
|
||||
email: string;
|
||||
comment: string;
|
||||
enable: boolean;
|
||||
}
|
||||
|
||||
export default function GroupRemoveClientsModal({
|
||||
open,
|
||||
groupName,
|
||||
members,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: GroupRemoveClientsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const rows = useMemo<ClientRow[]>(
|
||||
() =>
|
||||
(members || [])
|
||||
.map((c) => ({
|
||||
email: (c.email || '').trim(),
|
||||
comment: (c.comment || '').trim(),
|
||||
enable: c.enable !== false,
|
||||
}))
|
||||
.filter((r) => r.email),
|
||||
[members],
|
||||
);
|
||||
|
||||
// React resets this during render rather than in an effect so the modal's
|
||||
// first open frame already shows cleared fields.
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) {
|
||||
setSelectedEmails([]);
|
||||
setSearch('');
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(
|
||||
(r) => r.email.toLowerCase().includes(q) || r.comment.toLowerCase().includes(q),
|
||||
);
|
||||
}, [rows, search]);
|
||||
|
||||
const columns: ColumnsType<ClientRow> = useMemo(
|
||||
() => [
|
||||
{ title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
|
||||
{ title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
|
||||
{
|
||||
title: t('enable'),
|
||||
dataIndex: 'enable',
|
||||
key: 'enable',
|
||||
width: 90,
|
||||
render: (enabled: boolean) =>
|
||||
enabled ? (
|
||||
<Tag color="success">{t('enable')}</Tag>
|
||||
) : (
|
||||
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
if (!groupName || selectedEmails.length === 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await onSubmit(selectedEmails);
|
||||
if (!result) return;
|
||||
const affected = result.affected ?? selectedEmails.length;
|
||||
messageApi.success(
|
||||
t('pages.groups.removeFromGroupResult', { count: affected, name: groupName }),
|
||||
);
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={submit}
|
||||
okButtonProps={{ danger: true, disabled: selectedEmails.length === 0, loading: saving }}
|
||||
okText={t('remove')}
|
||||
cancelText={t('cancel')}
|
||||
title={t('pages.groups.removeFromGroupTitle', { name: groupName ?? '' })}
|
||||
width={680}
|
||||
>
|
||||
{messageContextHolder}
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('pages.groups.removeFromGroupDesc')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.inbounds.attachClientsSelectedCount', {
|
||||
selected: selectedEmails.length,
|
||||
total: rows.length,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Table<ClientRow>
|
||||
size="small"
|
||||
rowKey="email"
|
||||
columns={columns}
|
||||
dataSource={filteredRows}
|
||||
pagination={false}
|
||||
scroll={{ y: 280 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedEmails,
|
||||
onChange: (keys) => setSelectedEmails(keys as string[]),
|
||||
preserveSelectedRowKeys: true,
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,708 +0,0 @@
|
||||
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
ConfigProvider,
|
||||
Dropdown,
|
||||
Form,
|
||||
Input,
|
||||
Layout,
|
||||
Modal,
|
||||
Result,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { MenuProps, TableColumnsType } from 'antd';
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
ClockCircleOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
LinkOutlined,
|
||||
MoreOutlined,
|
||||
PieChartOutlined,
|
||||
PlusOutlined,
|
||||
RetweetOutlined,
|
||||
TagsOutlined,
|
||||
TeamOutlined,
|
||||
UsergroupAddOutlined,
|
||||
UsergroupDeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { usePageTitle } from '@/hooks/usePageTitle';
|
||||
import { useClients } from '@/hooks/useClients';
|
||||
import { HttpUtil, SizeFormatter } from '@/utils';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import { LazyMount } from '@/components/utility';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import {
|
||||
ClientRecordSchema,
|
||||
GroupSummaryListSchema,
|
||||
type ClientRecord,
|
||||
type GroupSummary,
|
||||
} from '@/schemas/client';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
|
||||
const ClientRecordListSchema = z
|
||||
.array(ClientRecordSchema)
|
||||
.nullable()
|
||||
.transform((v) => v ?? []);
|
||||
|
||||
const SubLinksModal = lazy(() => import('../clients/SubLinksModal'));
|
||||
const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal'));
|
||||
const GroupAddClientsModal = lazy(() => import('./GroupAddClientsModal'));
|
||||
const GroupRemoveClientsModal = lazy(() => import('./GroupRemoveClientsModal'));
|
||||
|
||||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
|
||||
|
||||
async function fetchGroups(): Promise<GroupSummary[]> {
|
||||
const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load groups');
|
||||
const validated = parseMsg(msg, GroupSummaryListSchema, 'clients/groups');
|
||||
return validated.obj ?? [];
|
||||
}
|
||||
|
||||
async function fetchEmailsForGroup(name: string): Promise<string[]> {
|
||||
const msg = await HttpUtil.get<string[]>(
|
||||
`/panel/api/clients/groups/${encodeURIComponent(name)}/emails`,
|
||||
undefined,
|
||||
{ silent: true },
|
||||
);
|
||||
if (!msg?.success || !Array.isArray(msg.obj)) return [];
|
||||
return msg.obj;
|
||||
}
|
||||
|
||||
export default function GroupsPage() {
|
||||
usePageTitle();
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
useEffect(() => {
|
||||
setMessageInstance(messageApi);
|
||||
}, [messageApi]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({
|
||||
list: false,
|
||||
});
|
||||
|
||||
const groupsQuery = useQuery({
|
||||
queryKey: keys.clients.groups(),
|
||||
queryFn: fetchGroups,
|
||||
});
|
||||
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
|
||||
const loading = groupsQuery.isFetching;
|
||||
const fetched = groupsQuery.data !== undefined || groupsQuery.isError;
|
||||
const fetchError = groupsQuery.error ? (groupsQuery.error as Error).message : '';
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: keys.clients.root() });
|
||||
}, [queryClient]);
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: { name: string }) =>
|
||||
HttpUtil.post('/panel/api/clients/groups/create', body, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const renameMut = useMutation({
|
||||
mutationFn: (body: { oldName: string; newName: string }) =>
|
||||
HttpUtil.post('/panel/api/clients/groups/rename', body, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (body: { name: string }) =>
|
||||
HttpUtil.post('/panel/api/clients/groups/delete', body, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const groupResetMut = useMutation({
|
||||
mutationFn: (body: { name: string }) =>
|
||||
HttpUtil.post('/panel/api/clients/groups/resetTraffic', body, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<GroupSummary | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
|
||||
const [subLinksOpen, setSubLinksOpen] = useState(false);
|
||||
const [adjustOpen, setAdjustOpen] = useState(false);
|
||||
const [addClientsOpen, setAddClientsOpen] = useState(false);
|
||||
const [removeClientsOpen, setRemoveClientsOpen] = useState(false);
|
||||
const [groupEmails, setGroupEmails] = useState<string[]>([]);
|
||||
const [groupForAction, setGroupForAction] = useState<GroupSummary | null>(null);
|
||||
|
||||
const allClientsQuery = useQuery<ClientRecord[]>({
|
||||
queryKey: keys.clients.all(),
|
||||
queryFn: async () => {
|
||||
const msg = await HttpUtil.get('/panel/api/clients/list', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load clients');
|
||||
const validated = parseMsg(msg, ClientRecordListSchema, 'clients/list');
|
||||
return validated.obj ?? [];
|
||||
},
|
||||
enabled: addClientsOpen || removeClientsOpen || subLinksOpen,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const allClients = allClientsQuery.data ?? [];
|
||||
|
||||
const totalGroups = groups.length;
|
||||
const totalClients = useMemo(
|
||||
() => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
|
||||
[groups],
|
||||
);
|
||||
const totalTraffic = useMemo(
|
||||
() => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
|
||||
[groups],
|
||||
);
|
||||
const totalUpload = useMemo(() => groups.reduce((acc, g) => acc + (g.up || 0), 0), [groups]);
|
||||
const totalDownload = useMemo(() => groups.reduce((acc, g) => acc + (g.down || 0), 0), [groups]);
|
||||
|
||||
function openCreate() {
|
||||
setCreateName('');
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
async function confirmCreate() {
|
||||
const name = createName.trim();
|
||||
if (!name) return;
|
||||
if (groups.some((g) => g.name.toLowerCase() === name.toLowerCase())) {
|
||||
messageApi.error(t('pages.groups.renameCollision', { name }));
|
||||
return;
|
||||
}
|
||||
const msg = await createMut.mutateAsync({ name });
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.groups.createSuccess', { name }));
|
||||
setCreateOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openRename(g: GroupSummary) {
|
||||
setRenameTarget(g);
|
||||
setRenameValue(g.name);
|
||||
setRenameOpen(true);
|
||||
}
|
||||
|
||||
async function confirmRename() {
|
||||
if (!renameTarget) return;
|
||||
const next = renameValue.trim();
|
||||
if (!next || next === renameTarget.name) {
|
||||
setRenameOpen(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
groups.some(
|
||||
(g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name,
|
||||
)
|
||||
) {
|
||||
messageApi.error(t('pages.groups.renameCollision', { name: next }));
|
||||
return;
|
||||
}
|
||||
const msg = await renameMut.mutateAsync({ oldName: renameTarget.name, newName: next });
|
||||
if (msg?.success) {
|
||||
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
|
||||
messageApi.success(t('pages.groups.renameSuccess', { count: affected }));
|
||||
setRenameOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(g: GroupSummary) {
|
||||
modal.confirm({
|
||||
title: t('pages.groups.deleteConfirmTitle', { name: g.name }),
|
||||
content: t('pages.groups.deleteConfirmContent', { count: g.clientCount }),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await deleteMut.mutateAsync({ name: g.name });
|
||||
if (msg?.success) {
|
||||
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
|
||||
messageApi.success(t('pages.groups.deleteSuccess', { count: affected }));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openSubLinksFor(g: GroupSummary) {
|
||||
if (!g.clientCount) {
|
||||
messageApi.info(t('pages.groups.emptyForAction'));
|
||||
return;
|
||||
}
|
||||
const emails = await fetchEmailsForGroup(g.name);
|
||||
if (emails.length === 0) {
|
||||
messageApi.info(t('pages.groups.emptyForAction'));
|
||||
return;
|
||||
}
|
||||
setGroupForAction(g);
|
||||
setGroupEmails(emails);
|
||||
setSubLinksOpen(true);
|
||||
}
|
||||
|
||||
async function openAdjustFor(g: GroupSummary) {
|
||||
if (!g.clientCount) {
|
||||
messageApi.info(t('pages.groups.emptyForAction'));
|
||||
return;
|
||||
}
|
||||
const emails = await fetchEmailsForGroup(g.name);
|
||||
if (emails.length === 0) {
|
||||
messageApi.info(t('pages.groups.emptyForAction'));
|
||||
return;
|
||||
}
|
||||
setGroupForAction(g);
|
||||
setGroupEmails(emails);
|
||||
setAdjustOpen(true);
|
||||
}
|
||||
|
||||
function openAddClientsFor(g: GroupSummary) {
|
||||
setGroupForAction(g);
|
||||
setAddClientsOpen(true);
|
||||
}
|
||||
|
||||
function openRemoveClientsFor(g: GroupSummary) {
|
||||
if (!g.clientCount) {
|
||||
messageApi.info(t('pages.groups.emptyForAction'));
|
||||
return;
|
||||
}
|
||||
setGroupForAction(g);
|
||||
setRemoveClientsOpen(true);
|
||||
}
|
||||
|
||||
function onDeleteClients(g: GroupSummary) {
|
||||
if (!g.clientCount) {
|
||||
messageApi.info(t('pages.groups.emptyForAction'));
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: t('pages.groups.deleteClientsConfirmTitle', { name: g.name }),
|
||||
content: t('pages.groups.deleteClientsConfirmContent', { count: g.clientCount }),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const emails = await fetchEmailsForGroup(g.name);
|
||||
if (emails.length === 0) return;
|
||||
const msg = await bulkDelete(emails);
|
||||
if (msg?.success) {
|
||||
const ok = msg.obj?.deleted ?? 0;
|
||||
const skipped = msg.obj?.skipped ?? [];
|
||||
const failed = skipped.length;
|
||||
if (failed === 0) {
|
||||
messageApi.success(t('pages.groups.deleteClientsSuccess', { count: ok }));
|
||||
} else {
|
||||
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
|
||||
messageApi.warning(
|
||||
firstError
|
||||
? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}`
|
||||
: t('pages.groups.deleteClientsMixed', { ok, failed }),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onResetTraffic(g: GroupSummary) {
|
||||
if (!g.clientCount) {
|
||||
messageApi.info(t('pages.groups.emptyForAction'));
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: t('pages.groups.resetConfirmTitle', { name: g.name }),
|
||||
content: t('pages.groups.resetConfirmContent'),
|
||||
okText: t('reset'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await groupResetMut.mutateAsync({ name: g.name });
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.groups.resetSuccess', { name: g.name }));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function rowActions(row: GroupSummary): MenuProps['items'] {
|
||||
return [
|
||||
{
|
||||
key: 'subLinks',
|
||||
icon: <LinkOutlined />,
|
||||
label: t('pages.clients.subLinksSelected', { count: row.clientCount || 0 }),
|
||||
disabled: !row.clientCount,
|
||||
onClick: () => openSubLinksFor(row),
|
||||
},
|
||||
{
|
||||
key: 'adjust',
|
||||
icon: <ClockCircleOutlined />,
|
||||
label: t('pages.clients.adjustSelected', { count: row.clientCount || 0 }),
|
||||
disabled: !row.clientCount,
|
||||
onClick: () => openAdjustFor(row),
|
||||
},
|
||||
{
|
||||
key: 'reset',
|
||||
icon: <RetweetOutlined />,
|
||||
label: t('pages.groups.resetTraffic'),
|
||||
disabled: !row.clientCount,
|
||||
onClick: () => onResetTraffic(row),
|
||||
},
|
||||
{
|
||||
key: 'addClients',
|
||||
icon: <UsergroupAddOutlined />,
|
||||
label: t('pages.groups.addToGroup'),
|
||||
onClick: () => openAddClientsFor(row),
|
||||
},
|
||||
{
|
||||
key: 'rename',
|
||||
icon: <EditOutlined />,
|
||||
label: t('pages.groups.rename'),
|
||||
onClick: () => openRename(row),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'removeClients',
|
||||
icon: <UsergroupDeleteOutlined />,
|
||||
label: t('pages.groups.removeFromGroup'),
|
||||
danger: true,
|
||||
disabled: !row.clientCount,
|
||||
onClick: () => openRemoveClientsFor(row),
|
||||
},
|
||||
{
|
||||
key: 'deleteClients',
|
||||
icon: <DeleteOutlined />,
|
||||
label: t('pages.groups.deleteClients'),
|
||||
danger: true,
|
||||
disabled: !row.clientCount,
|
||||
onClick: () => onDeleteClients(row),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
label: t('pages.groups.deleteGroupOnly'),
|
||||
danger: true,
|
||||
onClick: () => onDelete(row),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<GroupSummary> = [
|
||||
{
|
||||
title: t('pages.clients.actions'),
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
render: (_v, row) => (
|
||||
<Space size={4}>
|
||||
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
|
||||
<Button
|
||||
aria-label={t('more')}
|
||||
size="small"
|
||||
type="text"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<MoreOutlined />}
|
||||
/>
|
||||
</Dropdown>
|
||||
<Tooltip title={t('pages.groups.rename')}>
|
||||
<Button
|
||||
aria-label={t('pages.groups.rename')}
|
||||
size="small"
|
||||
type="text"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openRename(row)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.groups.name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string) => (
|
||||
<Tag color="geekblue" style={{ margin: 0, fontSize: 13 }}>
|
||||
{name}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.groups.clientCount'),
|
||||
dataIndex: 'clientCount',
|
||||
key: 'clientCount',
|
||||
width: 180,
|
||||
render: (count: number) => <span>{count || 0}</span>,
|
||||
},
|
||||
{
|
||||
title: t('pages.groups.upload'),
|
||||
dataIndex: 'up',
|
||||
key: 'up',
|
||||
width: 140,
|
||||
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
|
||||
},
|
||||
{
|
||||
title: t('pages.groups.download'),
|
||||
dataIndex: 'down',
|
||||
key: 'down',
|
||||
width: 140,
|
||||
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
|
||||
},
|
||||
{
|
||||
title: t('pages.groups.trafficUsed'),
|
||||
dataIndex: 'trafficUsed',
|
||||
key: 'trafficUsed',
|
||||
width: 160,
|
||||
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['groups-page'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
if (isUltra) classes.push('is-ultra');
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar />
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content id="content-layout" className="content-area">
|
||||
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
|
||||
{!fetched ? (
|
||||
<div className="loading-spacer" />
|
||||
) : fetchError ? (
|
||||
<Result
|
||||
status="error"
|
||||
title={t('somethingWentWrong')}
|
||||
subTitle={fetchError}
|
||||
extra={
|
||||
<Button type="primary" loading={loading} onClick={() => groupsQuery.refetch()}>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
|
||||
<Col span={24}>
|
||||
<Card size="small" hoverable className="summary-card">
|
||||
<Row gutter={[16, isMobile ? 16 : 12]}>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Statistic
|
||||
title={t('pages.groups.totalGroups')}
|
||||
value={String(totalGroups)}
|
||||
prefix={<TagsOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Statistic
|
||||
title={t('pages.groups.totalGroupedClients')}
|
||||
value={String(totalClients)}
|
||||
prefix={<TeamOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Statistic
|
||||
title={t('pages.groups.totalUpDown')}
|
||||
value={0}
|
||||
formatter={() => (
|
||||
<span>
|
||||
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totalUpload)}
|
||||
{' / '}
|
||||
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totalDownload)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={12} md={6}>
|
||||
<Statistic
|
||||
title={t('pages.groups.totalTraffic')}
|
||||
value={SizeFormatter.sizeFormat(totalTraffic)}
|
||||
prefix={<PieChartOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
title={
|
||||
<div className="card-toolbar">
|
||||
<Button
|
||||
aria-label={t('pages.groups.addGroup')}
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openCreate}
|
||||
>
|
||||
{!isMobile && t('pages.groups.addGroup')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Table<GroupSummary>
|
||||
dataSource={groups}
|
||||
columns={columns}
|
||||
rowKey="name"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="card-empty">
|
||||
<TagsOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title={t('pages.groups.addGroup')}
|
||||
okText={t('create')}
|
||||
cancelText={t('cancel')}
|
||||
confirmLoading={createMut.isPending}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={confirmCreate}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('pages.groups.name')}>
|
||||
<Input
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onPressEnter={confirmCreate}
|
||||
placeholder={t('pages.clients.groupPlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={renameOpen}
|
||||
title={renameTarget ? t('pages.groups.renameTitle', { name: renameTarget.name }) : ''}
|
||||
okText={t('save')}
|
||||
cancelText={t('cancel')}
|
||||
confirmLoading={renameMut.isPending}
|
||||
onCancel={() => setRenameOpen(false)}
|
||||
onOk={confirmRename}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('pages.groups.name')}>
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onPressEnter={confirmRename}
|
||||
placeholder={t('pages.clients.groupPlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<LazyMount when={subLinksOpen}>
|
||||
<SubLinksModal
|
||||
open={subLinksOpen}
|
||||
emails={groupEmails}
|
||||
clients={allClients}
|
||||
subSettings={subSettings}
|
||||
onOpenChange={setSubLinksOpen}
|
||||
/>
|
||||
</LazyMount>
|
||||
|
||||
<LazyMount when={adjustOpen}>
|
||||
<ClientBulkAdjustModal
|
||||
open={adjustOpen}
|
||||
count={groupEmails.length}
|
||||
onOpenChange={setAdjustOpen}
|
||||
onSubmit={async (addDays, addBytes) => {
|
||||
const msg = await bulkAdjust(groupEmails, addDays, addBytes);
|
||||
if (msg?.success) {
|
||||
const obj = msg.obj ?? { adjusted: 0 };
|
||||
messageApi.success(
|
||||
t('pages.groups.adjustSuccess', {
|
||||
count: obj.adjusted ?? 0,
|
||||
name: groupForAction?.name ?? '',
|
||||
}),
|
||||
);
|
||||
return obj;
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</LazyMount>
|
||||
|
||||
<LazyMount when={addClientsOpen}>
|
||||
<GroupAddClientsModal
|
||||
open={addClientsOpen}
|
||||
groupName={groupForAction?.name ?? null}
|
||||
candidates={allClients.filter((c) => c.group !== groupForAction?.name)}
|
||||
onClose={() => setAddClientsOpen(false)}
|
||||
onSubmit={async (emails) => {
|
||||
const msg = await bulkAddToGroup(emails, groupForAction?.name ?? '');
|
||||
if (msg?.success) {
|
||||
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</LazyMount>
|
||||
|
||||
<LazyMount when={removeClientsOpen}>
|
||||
<GroupRemoveClientsModal
|
||||
open={removeClientsOpen}
|
||||
groupName={groupForAction?.name ?? null}
|
||||
members={allClients.filter((c) => c.group === groupForAction?.name)}
|
||||
onClose={() => setRemoveClientsOpen(false)}
|
||||
onSubmit={async (emails) => {
|
||||
const msg = await bulkRemoveFromGroup(emails);
|
||||
if (msg?.success) {
|
||||
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</LazyMount>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,6 @@ const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
|
||||
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
|
||||
const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
|
||||
const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
|
||||
const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
|
||||
|
||||
type RowAction =
|
||||
| 'edit'
|
||||
@@ -68,7 +67,6 @@ type RowAction =
|
||||
| 'attachClients'
|
||||
| 'attachExisting'
|
||||
| 'detachClients'
|
||||
| 'addToGroup'
|
||||
| 'clone';
|
||||
|
||||
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
||||
@@ -165,9 +163,6 @@ export default function InboundsPage() {
|
||||
const [detachOpen, setDetachOpen] = useState(false);
|
||||
const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
|
||||
|
||||
const [groupOpen, setGroupOpen] = useState(false);
|
||||
const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
|
||||
|
||||
const [cloneOpen, setCloneOpen] = useState(false);
|
||||
const [cloneSource, setCloneSource] = useState<DBInbound | null>(null);
|
||||
|
||||
@@ -622,7 +617,6 @@ export default function InboundsPage() {
|
||||
'clipboard',
|
||||
'clone',
|
||||
'attachClients',
|
||||
'addToGroup',
|
||||
];
|
||||
let target = dbInbound;
|
||||
if (hydratingKeys.includes(key)) {
|
||||
@@ -672,10 +666,6 @@ export default function InboundsPage() {
|
||||
setDetachSource(target);
|
||||
setDetachOpen(true);
|
||||
break;
|
||||
case 'addToGroup':
|
||||
setGroupSource(target);
|
||||
setGroupOpen(true);
|
||||
break;
|
||||
case 'clone':
|
||||
confirmClone(target);
|
||||
break;
|
||||
@@ -848,14 +838,6 @@ export default function InboundsPage() {
|
||||
source={detachSource}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={groupOpen}>
|
||||
<AddClientsToGroupModal
|
||||
open={groupOpen}
|
||||
onClose={() => setGroupOpen(false)}
|
||||
onAdded={refresh}
|
||||
source={groupSource}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={cloneOpen}>
|
||||
<CloneInboundModal
|
||||
open={cloneOpen}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||
|
||||
const BulkAddToGroupModal = lazy(() => import('@/pages/clients/BulkAddToGroupModal'));
|
||||
|
||||
interface AddClientsToGroupModalProps {
|
||||
open: boolean;
|
||||
source: DBInbound | null;
|
||||
onClose: () => void;
|
||||
onAdded?: () => void;
|
||||
}
|
||||
|
||||
function readClientEmails(settings: unknown): string[] {
|
||||
const parsed = coerceInboundJsonField(settings) as { clients?: Array<{ email?: string }> };
|
||||
const clients = Array.isArray(parsed?.clients) ? parsed.clients : [];
|
||||
return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export default function AddClientsToGroupModal({
|
||||
open,
|
||||
source,
|
||||
onClose,
|
||||
onAdded,
|
||||
}: AddClientsToGroupModalProps) {
|
||||
const [groups, setGroups] = useState<string[]>([]);
|
||||
|
||||
const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
|
||||
if (cancelled) return;
|
||||
const list = Array.isArray(msg?.obj) ? (msg.obj as Array<{ name?: string }>) : [];
|
||||
setGroups(list.map((g) => g?.name || '').filter(Boolean));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<BulkAddToGroupModal
|
||||
open={open}
|
||||
count={emails.length}
|
||||
groups={groups}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
onSubmit={async (group) => {
|
||||
const msg = await HttpUtil.post(
|
||||
'/panel/api/clients/groups/bulkAdd',
|
||||
{ emails, group },
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
if (!msg?.success) return null;
|
||||
onAdded?.();
|
||||
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Alert, Button, Form, Input, InputNumber, Radio, Select, Space } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { ForwardingSpec } from '@/schemas/forms/inbound-form';
|
||||
|
||||
interface ForwardTarget {
|
||||
id: number;
|
||||
remark: string;
|
||||
tag: string;
|
||||
protocol: string;
|
||||
listen: string;
|
||||
port: number;
|
||||
enable: boolean;
|
||||
nodeId: number | null;
|
||||
nodeName: string;
|
||||
}
|
||||
|
||||
// Parse a landing link into manual-forwarding fields. Accepts the plain
|
||||
// URL form (socks5://user:pass@host:port, also socks:// and http://) and
|
||||
// the whole-link base64 variant common in share links.
|
||||
export function parseLandingLink(raw: string): Partial<ForwardingSpec> | null {
|
||||
const link = raw.trim();
|
||||
if (!link) return null;
|
||||
const tryUrl = (s: string): Partial<ForwardingSpec> | null => {
|
||||
try {
|
||||
const u = new URL(s);
|
||||
if (!['socks5:', 'socks:', 'http:', 'https:'].includes(u.protocol)) return null;
|
||||
const port = u.port ? parseInt(u.port, 10) : 0;
|
||||
if (!u.hostname || !port) return null;
|
||||
return {
|
||||
protocol: u.protocol === 'http:' || u.protocol === 'https:' ? 'http' : 'socks5',
|
||||
address: u.hostname,
|
||||
port,
|
||||
username: decodeURIComponent(u.username || ''),
|
||||
password: decodeURIComponent(u.password || ''),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const direct = tryUrl(link);
|
||||
if (direct) return direct;
|
||||
try {
|
||||
const decoded = atob(link.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return tryUrl(decoded.includes('://') ? decoded : `socks5://${decoded}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ForwardingTab({ currentId }: { currentId?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { control, setValue } = useFormContext();
|
||||
const mode = useWatch({ control, name: 'forwarding.mode' });
|
||||
const landing = useWatch({ control, name: 'forwarding.landing' });
|
||||
const [targets, setTargets] = useState<ForwardTarget[]>([]);
|
||||
const [link, setLink] = useState('');
|
||||
const [linkError, setLinkError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
HttpUtil.get('/panel/api/inbounds/forwardTargets')
|
||||
.then((msg) => {
|
||||
if (alive && msg?.success && Array.isArray(msg.obj)) {
|
||||
setTargets(msg.obj as ForwardTarget[]);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const applyLink = () => {
|
||||
const parsed = parseLandingLink(link);
|
||||
if (!parsed) {
|
||||
setLinkError(t('pages.inbounds.forwarding.importFailed'));
|
||||
return;
|
||||
}
|
||||
setLinkError('');
|
||||
const opts = { shouldDirty: true, shouldValidate: true };
|
||||
setValue('forwarding.protocol', parsed.protocol ?? 'socks5', opts);
|
||||
setValue('forwarding.address', parsed.address ?? '', opts);
|
||||
setValue('forwarding.port', parsed.port ?? 0, opts);
|
||||
setValue('forwarding.username', parsed.username ?? '', opts);
|
||||
setValue('forwarding.password', parsed.password ?? '', opts);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField name="forwarding.mode" label={t('pages.inbounds.forwarding.mode')}>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ value: 'direct', label: t('pages.inbounds.forwarding.direct') },
|
||||
{ value: 'forward', label: t('pages.inbounds.forwarding.forward') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{mode === 'forward' && (
|
||||
<>
|
||||
<FormField name="forwarding.landing" label={t('pages.inbounds.forwarding.landing')}>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ value: 'managed', label: t('pages.inbounds.forwarding.managed') },
|
||||
{ value: 'manual', label: t('pages.inbounds.forwarding.manual') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{landing === 'managed' ? (
|
||||
<FormField
|
||||
name="forwarding.targetInboundId"
|
||||
label={t('pages.inbounds.forwarding.target')}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder={t('pages.inbounds.forwarding.targetPlaceholder')}
|
||||
options={targets
|
||||
.filter((x) => x.id !== currentId)
|
||||
.map((x) => ({
|
||||
value: x.id,
|
||||
disabled: !x.enable,
|
||||
label: `${x.nodeName || t('pages.inbounds.localPanel')} · ${x.remark || x.tag} (${x.listen || '*'}:${x.port})${x.enable ? '' : ` (${t('pages.inbounds.forwarding.targetDisabled')})`}`,
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
) : (
|
||||
<>
|
||||
<FormField name="forwarding.address" label={t('pages.inbounds.forwarding.address')}>
|
||||
<Input placeholder="10.240.64.2" />
|
||||
</FormField>
|
||||
<FormField name="forwarding.port" label={t('pages.inbounds.forwarding.port')}>
|
||||
<InputNumber min={1} max={65535} />
|
||||
</FormField>
|
||||
<FormField name="forwarding.protocol" label={t('pages.inbounds.forwarding.protocol')}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name="forwarding.username" label={t('pages.inbounds.forwarding.username')}>
|
||||
<Input autoComplete="off" />
|
||||
</FormField>
|
||||
<FormField name="forwarding.password" label={t('pages.inbounds.forwarding.password')}>
|
||||
<Input.Password autoComplete="off" />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.forwarding.importLink')}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
placeholder={t('pages.inbounds.forwarding.importPlaceholder')}
|
||||
/>
|
||||
<Button onClick={applyLink}>{t('pages.inbounds.forwarding.importApply')}</Button>
|
||||
</Space.Compact>
|
||||
{linkError && <div style={{ color: '#ff4d4f', marginTop: 4 }}>{linkError}</div>}
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Alert type="info" showIcon message={t('pages.inbounds.forwarding.help')} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,7 @@ import { useSecurityActions } from './useSecurityActions';
|
||||
import { useInboundFallbacks } from './useInboundFallbacks';
|
||||
import FallbacksCard from './FallbacksCard';
|
||||
import SniffingTab from './SniffingTab';
|
||||
import ForwardingTab from './ForwardingTab';
|
||||
|
||||
import type { DBInbound } from '@/models/dbinbound';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
@@ -150,6 +151,7 @@ function firstRhfValidationIssue(
|
||||
function tabForValidationPath(path: PropertyKey[]): string {
|
||||
if (path[0] === 'settings') return 'protocol';
|
||||
if (path[0] === 'sniffing') return 'sniffing';
|
||||
if (path[0] === 'forwarding') return 'forwarding';
|
||||
if (path[0] === 'streamSettings') {
|
||||
if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings')
|
||||
return 'security';
|
||||
@@ -618,7 +620,6 @@ export default function InboundFormModal({
|
||||
<FormField name="nodeId" label={t('pages.inbounds.deployTo')}>
|
||||
<Select
|
||||
showSearch
|
||||
disabled={mode === 'edit'}
|
||||
placeholder={t('pages.inbounds.localPanel')}
|
||||
allowClear
|
||||
options={selectableNodes.map((n) => ({
|
||||
@@ -1079,6 +1080,10 @@ export default function InboundFormModal({
|
||||
|
||||
const sniffingTab = <SniffingTab />;
|
||||
|
||||
const forwardingTab = (
|
||||
<ForwardingTab currentId={mode === 'edit' && dbInbound ? dbInbound.id : undefined} />
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
@@ -1112,6 +1117,12 @@ export default function InboundFormModal({
|
||||
children: basicTab,
|
||||
forceRender: true,
|
||||
},
|
||||
{
|
||||
key: 'forwarding',
|
||||
label: t('pages.inbounds.forwardingTab'),
|
||||
children: forwardingTab,
|
||||
forceRender: true,
|
||||
},
|
||||
...((
|
||||
[
|
||||
Protocols.VLESS,
|
||||
|
||||
@@ -180,3 +180,11 @@
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.forwarding-tag {
|
||||
max-width: 180px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@@ -137,11 +137,17 @@ export default function InboundList({
|
||||
if (ok) setSelectedRowKeys([]);
|
||||
}, [onBulkDelete, selectedRowKeys]);
|
||||
|
||||
const inboundsById = useMemo(
|
||||
() => new Map(dbInbounds.map((ib) => [ib.id, ib] as const)),
|
||||
[dbInbounds],
|
||||
);
|
||||
|
||||
const columns = useInboundColumns({
|
||||
hasAnyRemark,
|
||||
hasAnySubSortIndex,
|
||||
hasActiveNode,
|
||||
nodesById,
|
||||
inboundsById,
|
||||
clientCount,
|
||||
inboundSpeed,
|
||||
subEnable,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
BlockOutlined,
|
||||
DeleteOutlined,
|
||||
InfoCircleOutlined,
|
||||
TagsOutlined,
|
||||
UsergroupAddOutlined,
|
||||
UsergroupDeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -91,11 +90,6 @@ export function buildRowActionsMenu({
|
||||
icon: <UsergroupDeleteOutlined />,
|
||||
label: t('pages.inbounds.detachClients'),
|
||||
});
|
||||
items.push({
|
||||
key: 'addToGroup',
|
||||
icon: <TagsOutlined />,
|
||||
label: t('pages.inbounds.addClientsToGroup'),
|
||||
});
|
||||
items.push({ type: 'divider' });
|
||||
items.push({
|
||||
key: 'delAllClients',
|
||||
|
||||
@@ -90,6 +90,8 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
|
||||
case 'mtproto':
|
||||
case 'wireguard':
|
||||
case 'amneziawg':
|
||||
case 'socks':
|
||||
case 'mixed':
|
||||
return true;
|
||||
case 'shadowsocks':
|
||||
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
|
||||
@@ -104,3 +106,26 @@ export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Landing label for the list-row forwarding chip: the managed target's
|
||||
// remark (falling back to its tag), or the manual endpoint address:port.
|
||||
// Returns null when the inbound does not forward, so the caller renders
|
||||
// no chip. The stored spec rides the row as a JSON string.
|
||||
export function forwardingLandingLabel(
|
||||
record: DBInboundRecord,
|
||||
inboundsById: Map<number, DBInboundRecord>,
|
||||
): string | null {
|
||||
const spec = coerceInboundJsonField(record.forwarding) as {
|
||||
mode?: string;
|
||||
landing?: string;
|
||||
targetInboundId?: number;
|
||||
address?: string;
|
||||
port?: number;
|
||||
};
|
||||
if (spec.mode !== 'forward') return null;
|
||||
if (spec.landing === 'manual') {
|
||||
return spec.address ? `${spec.address}:${spec.port ?? 0}` : 'SK5';
|
||||
}
|
||||
const target = spec.targetInboundId ? inboundsById.get(spec.targetInboundId) : undefined;
|
||||
return target?.remark || target?.tag || 'SK5';
|
||||
}
|
||||
|
||||
@@ -32,8 +32,10 @@ export interface DBInboundRecord extends ProtocolFlags {
|
||||
expiryTime: number;
|
||||
_expiryTime: { valueOf(): number } | null;
|
||||
nodeId?: number | null;
|
||||
tag?: string;
|
||||
settings: unknown;
|
||||
streamSettings: unknown;
|
||||
forwarding?: unknown;
|
||||
}
|
||||
|
||||
export interface ClientCountEntry {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/components/utility/speedTagStyle';
|
||||
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
|
||||
import {
|
||||
forwardingLandingLabel,
|
||||
readStreamHints,
|
||||
networkLabel,
|
||||
networkL4,
|
||||
@@ -31,6 +32,7 @@ interface UseInboundColumnsParams {
|
||||
hasAnySubSortIndex: boolean;
|
||||
hasActiveNode: boolean;
|
||||
nodesById: Map<number, NodeRecord>;
|
||||
inboundsById: Map<number, DBInboundRecord>;
|
||||
clientCount: Record<number, ClientCountEntry>;
|
||||
inboundSpeed: Record<number, InboundSpeedEntry>;
|
||||
subEnable: boolean;
|
||||
@@ -45,6 +47,7 @@ export function useInboundColumns({
|
||||
hasAnySubSortIndex,
|
||||
hasActiveNode,
|
||||
nodesById,
|
||||
inboundsById,
|
||||
clientCount,
|
||||
inboundSpeed,
|
||||
subEnable,
|
||||
@@ -257,6 +260,17 @@ export function useInboundColumns({
|
||||
</Tag>,
|
||||
);
|
||||
}
|
||||
const fwdLabel = forwardingLandingLabel(record, inboundsById);
|
||||
if (fwdLabel) {
|
||||
const text = `${t('pages.inbounds.forwardingTab')} → ${fwdLabel}`;
|
||||
tags.push(
|
||||
<Tooltip key="fwd" title={text}>
|
||||
<Tag color="volcano" className="forwarding-tag">
|
||||
{text}
|
||||
</Tag>
|
||||
</Tooltip>,
|
||||
);
|
||||
}
|
||||
return <div className="protocol-tags">{tags}</div>;
|
||||
},
|
||||
},
|
||||
@@ -453,6 +467,7 @@ export function useInboundColumns({
|
||||
hasAnySubSortIndex,
|
||||
hasActiveNode,
|
||||
nodesById,
|
||||
inboundsById,
|
||||
clientCount,
|
||||
inboundSpeed,
|
||||
subEnable,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
.awglog-events-title {
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.awglog-event-line {
|
||||
padding: 2px 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.xraylog-table .log-row-offline {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Checkbox, Empty, Form, Input, Modal, Select, Tag } from 'antd';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, FileManager, IntlUtil, PromiseUtil, SizeFormatter } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import type { AmneziaWGLogs } from '@/generated/types';
|
||||
import './XrayLogModal.css';
|
||||
import './AmneziaWGLogModal.css';
|
||||
|
||||
interface AmneziaWGLogModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AUTO_UPDATE_INTERVAL = 5000;
|
||||
|
||||
function shortTime(value?: number): string {
|
||||
if (!value) return '';
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { datepicker } = useDatepicker();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [rows, setRows] = useState('50');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [autoUpdate, setAutoUpdate] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [logs, setLogs] = useState<Partial<AmneziaWGLogs>>({});
|
||||
|
||||
const peers = useMemo(() => logs.peers ?? [], [logs.peers]);
|
||||
const events = useMemo(() => logs.events ?? [], [logs.events]);
|
||||
|
||||
const runRefresh = useCallback(async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.post<AmneziaWGLogs>(`/panel/api/server/amneziawglogs/${rows}`, {
|
||||
filter,
|
||||
});
|
||||
if (msg?.success) setLogs(msg.obj || {});
|
||||
await PromiseUtil.sleep(300);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [rows, filter]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
void runRefresh();
|
||||
}, [runRefresh]);
|
||||
|
||||
const refreshRef = useRef(refresh);
|
||||
useEffect(() => {
|
||||
refreshRef.current = refresh;
|
||||
});
|
||||
|
||||
// The spinner is raised during render so the fetch effect stays side-effect
|
||||
// free until its response lands.
|
||||
const refreshKey = open ? `${rows}|${filter}` : null;
|
||||
const [loadingKey, setLoadingKey] = useState<string | null>(null);
|
||||
if (refreshKey !== loadingKey) {
|
||||
setLoadingKey(refreshKey);
|
||||
if (refreshKey) setLoading(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void runRefresh();
|
||||
}, [open, rows, filter, runRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !autoUpdate) return;
|
||||
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [open, autoUpdate]);
|
||||
|
||||
function fullDate(value?: number): string {
|
||||
return value ? IntlUtil.formatDate(value, datepicker) : '';
|
||||
}
|
||||
|
||||
function download() {
|
||||
const peerLines = peers.map((p) => {
|
||||
const at = p.handshake ? new Date(p.handshake).toISOString() : 'never';
|
||||
return `${at} IFACE=${p.interface || ''} INBOUND=${p.tag || ''} EMAIL=${p.email || ''} ENDPOINT=${p.endpoint || '-'} ALLOWEDIPS=${p.allowedIPs || ''} UP=${p.up ?? 0} DOWN=${p.down ?? 0} ONLINE=${p.online ? 'yes' : 'no'}`;
|
||||
});
|
||||
FileManager.downloadTextFile([...peerLines, '', ...events].join('\n'), 'amneziawg.log');
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
footer={null}
|
||||
width={isMobile ? '100vw' : '80vw'}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
className={isMobile ? 'xraylog-modal-mobile' : undefined}
|
||||
onCancel={onClose}
|
||||
title={
|
||||
<>
|
||||
{t('pages.index.amneziawgLogs')}
|
||||
<SyncOutlined
|
||||
spin={loading}
|
||||
className="reload-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('refresh')}
|
||||
onClick={refresh}
|
||||
onKeyDown={activateOnKey(refresh)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form layout="inline" className="log-toolbar">
|
||||
<Form.Item>
|
||||
<Select
|
||||
value={rows}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
onChange={setRows}
|
||||
options={[
|
||||
{ value: '20', label: '20' },
|
||||
{ value: '50', label: '50' },
|
||||
{ value: '100', label: '100' },
|
||||
{ value: '500', label: '500' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('filter')} className="filter-item">
|
||||
<Input
|
||||
value={filter}
|
||||
size="small"
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter') refresh();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
|
||||
{t('pages.index.autoUpdate')}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item className="download-item">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={download}
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label={t('download')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
|
||||
{peers.length === 0 ? (
|
||||
<div className="log-empty">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={t('pages.index.amneziawgNoPeers')}
|
||||
/>
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
peers.map((peer, idx) => (
|
||||
<div key={idx} className="log-card">
|
||||
<div className="log-card-head">
|
||||
<span className="log-time" title={fullDate(peer.handshake)}>
|
||||
{shortTime(peer.handshake) || '—'}
|
||||
</span>
|
||||
<Tag color={peer.online ? 'green' : 'default'} className="log-event-tag">
|
||||
{peer.online ? t('online') : t('pages.index.amneziawgIdle')}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="log-route">
|
||||
<span className="log-addr">{peer.endpoint || '—'}</span>
|
||||
<span className="log-arrow">→</span>
|
||||
<span className="log-addr">{peer.allowedIPs}</span>
|
||||
</div>
|
||||
<div className="log-meta">
|
||||
<span className="log-meta-pair">
|
||||
<span className="log-meta-key">iface</span>
|
||||
<span className="log-meta-val">{peer.interface}</span>
|
||||
</span>
|
||||
<span className="log-meta-pair">
|
||||
<span className="log-meta-key">inbound</span>
|
||||
<span className="log-meta-val">{peer.tag}</span>
|
||||
</span>
|
||||
{peer.email && (
|
||||
<span className="log-meta-pair">
|
||||
<span className="log-meta-key">email</span>
|
||||
<span className="log-meta-val">{peer.email}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="log-meta-pair">
|
||||
<span className="log-meta-key">↑↓</span>
|
||||
<span className="log-meta-val">
|
||||
{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<table className="xraylog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('pages.index.amneziawgHandshake')}</th>
|
||||
<th>{t('pages.index.amneziawgInterface')}</th>
|
||||
<th>{t('pages.index.amneziawgInbound')}</th>
|
||||
<th>Email</th>
|
||||
<th>{t('pages.index.amneziawgEndpoint')}</th>
|
||||
<th>{t('pages.clients.amneziaWgAllowedIPs')}</th>
|
||||
<th>↑ / ↓</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{peers.map((peer, idx) => (
|
||||
<tr key={idx} className={peer.online ? undefined : 'log-row-offline'}>
|
||||
<td>
|
||||
<b>{fullDate(peer.handshake) || '—'}</b>
|
||||
</td>
|
||||
<td>{peer.interface}</td>
|
||||
<td>{peer.tag}</td>
|
||||
<td>{peer.email}</td>
|
||||
<td>{peer.endpoint || '—'}</td>
|
||||
<td>{peer.allowedIPs}</td>
|
||||
<td>{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="awglog-events-title">{t('pages.index.amneziawgEvents')}</div>
|
||||
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
|
||||
{events.length === 0 ? (
|
||||
<div className="log-empty">{t('pages.index.amneziawgNoEvents')}</div>
|
||||
) : (
|
||||
events.map((line, idx) => (
|
||||
<div key={idx} className="awglog-event-line">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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,53 +38,6 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ov-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--ov-line);
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.ov-state-dot {
|
||||
position: relative;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ov-state[data-state='running'] .ov-state-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid currentColor;
|
||||
animation: ovPulse 1.6s infinite ease-out;
|
||||
}
|
||||
|
||||
@keyframes ovPulse {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ov-state[data-state='running'] .ov-state-dot::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ov-state-version,
|
||||
.ov-panel-version {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -95,8 +48,6 @@
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.ov-state-version:hover,
|
||||
.ov-state-version:focus-visible,
|
||||
.ov-panel-version:hover,
|
||||
.ov-panel-version:focus-visible {
|
||||
color: var(--ant-color-primary);
|
||||
@@ -112,11 +63,6 @@
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.ov-error-detail {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ov-bar-actions {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
|
||||
@@ -32,13 +32,7 @@ import { mean, peak, useOverviewHistory } from './useOverviewHistory';
|
||||
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
||||
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
||||
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
||||
const LogModal = lazy(() => import('./LogModal'));
|
||||
const BackupModal = lazy(() => import('./BackupModal'));
|
||||
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() {
|
||||
@@ -51,7 +45,8 @@ export default function IndexPage() {
|
||||
setMessageInstance(messageApi);
|
||||
}, [messageApi]);
|
||||
|
||||
const [accessLogEnable, setAccessLogEnable] = useState(false);
|
||||
const history = useOverviewHistory(status, fetched && !fetchError);
|
||||
|
||||
const [devChannelEnable, setDevChannelEnable] = useState(false);
|
||||
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
||||
currentVersion: '',
|
||||
@@ -62,30 +57,21 @@ export default function IndexPage() {
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
|
||||
const [showIp, setShowIp] = useState(false);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [backupOpen, setBackupOpen] = useState(false);
|
||||
const [panelUpdateOpen, setPanelUpdateOpen] = useState(false);
|
||||
const [sysHistoryOpen, setSysHistoryOpen] = useState(false);
|
||||
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);
|
||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||
|
||||
const history = useOverviewHistory(status, fetched && !fetchError);
|
||||
|
||||
useEffect(() => {
|
||||
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
|
||||
'/panel/api/setting/defaultSettings',
|
||||
).then((msg) => {
|
||||
if (msg?.success && msg.obj) {
|
||||
setAccessLogEnable(!!msg.obj.accessLogEnable);
|
||||
setDevChannelEnable(!!msg.obj.devChannelEnable);
|
||||
}
|
||||
});
|
||||
HttpUtil.post<{ devChannelEnable?: boolean }>('/panel/api/setting/defaultSettings').then(
|
||||
(msg) => {
|
||||
if (msg?.success && msg.obj) {
|
||||
setDevChannelEnable(!!msg.obj.devChannelEnable);
|
||||
}
|
||||
},
|
||||
);
|
||||
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||
});
|
||||
@@ -101,16 +87,6 @@ export default function IndexPage() {
|
||||
if (tip) setLoadingTip(tip);
|
||||
}, []);
|
||||
|
||||
const stopXray = useCallback(async () => {
|
||||
await HttpUtil.post('/panel/api/server/stopXrayService');
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const restartXray = useCallback(async () => {
|
||||
await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function handleChannelChange(dev: boolean) {
|
||||
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
|
||||
if (!res?.success) return;
|
||||
@@ -194,23 +170,22 @@ export default function IndexPage() {
|
||||
) : (
|
||||
<div className="ov-page">
|
||||
<OverviewActionBar
|
||||
status={status}
|
||||
isMobile={isMobile}
|
||||
accessLogEnable={accessLogEnable}
|
||||
panelVersion={displayVersion}
|
||||
latestVersion={panelUpdateInfo.latestVersion}
|
||||
updateAvailable={panelUpdateInfo.updateAvailable}
|
||||
onStopXray={stopXray}
|
||||
onRestartXray={restartXray}
|
||||
onOpenLogs={() => setLogsOpen(true)}
|
||||
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
||||
onOpenAmneziaWGLogs={() => setAmneziawgLogsOpen(true)}
|
||||
latestVersion={
|
||||
panelUpdateInfo.channel === 'dev'
|
||||
? panelUpdateInfo.latestVersion
|
||||
: panelUpdateInfo.displayLatestVersion || panelUpdateInfo.latestVersion
|
||||
}
|
||||
updateAvailable={
|
||||
panelUpdateInfo.channel === 'dev'
|
||||
? panelUpdateInfo.updateAvailable
|
||||
: (panelUpdateInfo.displayUpdateAvailable ??
|
||||
panelUpdateInfo.updateAvailable)
|
||||
}
|
||||
onOpenConfig={openConfig}
|
||||
onOpenBackup={() => setBackupOpen(true)}
|
||||
onOpenSystemHistory={() => setSysHistoryOpen(true)}
|
||||
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
|
||||
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
||||
onOpenVersionSwitch={() => setVersionOpen(true)}
|
||||
/>
|
||||
|
||||
{health && (
|
||||
@@ -307,9 +282,6 @@ export default function IndexPage() {
|
||||
onBusy={setBusy}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={logsOpen}>
|
||||
<LogModal open={logsOpen} onClose={() => setLogsOpen(false)} />
|
||||
</LazyMount>
|
||||
<LazyMount when={backupOpen}>
|
||||
<BackupModal
|
||||
open={backupOpen}
|
||||
@@ -318,31 +290,6 @@ export default function IndexPage() {
|
||||
onBusy={setBusy}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={sysHistoryOpen}>
|
||||
<SystemHistoryModal
|
||||
open={sysHistoryOpen}
|
||||
status={status}
|
||||
onClose={() => setSysHistoryOpen(false)}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={xrayMetricsOpen}>
|
||||
<XrayMetricsModal open={xrayMetricsOpen} onClose={() => setXrayMetricsOpen(false)} />
|
||||
</LazyMount>
|
||||
<LazyMount when={xrayLogsOpen}>
|
||||
<XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
|
||||
</LazyMount>
|
||||
<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}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
.reload-icon {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.log-toolbar .download-item {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
--log-stamp: #3c89e8;
|
||||
--log-debug: #3c89e8;
|
||||
--log-info: #008771;
|
||||
--log-notice: #008771;
|
||||
--log-warning: #f37b24;
|
||||
--log-error: #e04141;
|
||||
--log-unknown: #595959;
|
||||
--log-divider: rgba(128, 128, 128, 0.18);
|
||||
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.log-stamp {
|
||||
color: var(--log-stamp);
|
||||
}
|
||||
|
||||
.log-level {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.level-debug {
|
||||
color: var(--log-debug);
|
||||
}
|
||||
|
||||
.level-info {
|
||||
color: var(--log-info);
|
||||
}
|
||||
|
||||
.level-notice {
|
||||
color: var(--log-notice);
|
||||
}
|
||||
|
||||
.level-warning {
|
||||
color: var(--log-warning);
|
||||
}
|
||||
|
||||
.level-error {
|
||||
color: var(--log-error);
|
||||
}
|
||||
|
||||
.level-unknown {
|
||||
color: var(--log-unknown);
|
||||
}
|
||||
|
||||
.log-container-mobile {
|
||||
padding: 8px;
|
||||
white-space: normal;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.log-line + .log-line {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
border-bottom: 1px solid var(--log-divider);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-card:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.log-date {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.log-level-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid currentColor;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.log-body {
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-body-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
body.dark .log-container {
|
||||
--log-stamp: #6aa6ee;
|
||||
--log-debug: #6aa6ee;
|
||||
--log-info: #4ed3a6;
|
||||
--log-notice: #4ed3a6;
|
||||
--log-warning: #ffb872;
|
||||
--log-error: #ff7575;
|
||||
--log-unknown: #b5b5b5;
|
||||
--log-divider: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .log-container {
|
||||
--log-stamp: #7fb6f1;
|
||||
--log-debug: #7fb6f1;
|
||||
--log-info: #5fd9b0;
|
||||
--log-notice: #5fd9b0;
|
||||
--log-warning: #ffcc88;
|
||||
--log-error: #ff8a8a;
|
||||
--log-unknown: #c4c4c4;
|
||||
--log-divider: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.logmodal-mobile .ant-modal-content {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.logmodal-mobile .ant-modal-body {
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Checkbox, Form, Modal, Select, Space } from 'antd';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { parseLogLine } from './logParse';
|
||||
import './LogModal.css';
|
||||
|
||||
interface LogModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AUTO_UPDATE_INTERVAL = 5000;
|
||||
|
||||
export default function LogModal({ open, onClose }: LogModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [rows, setRows] = useState('20');
|
||||
const [level, setLevel] = useState('info');
|
||||
const [syslog, setSyslog] = useState(false);
|
||||
const [autoUpdate, setAutoUpdate] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
|
||||
const runRefresh = useCallback(async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.post<string[]>(`/panel/api/server/logs/${rows}`, {
|
||||
level,
|
||||
syslog,
|
||||
});
|
||||
if (msg?.success) {
|
||||
setLogs(msg.obj || []);
|
||||
}
|
||||
await PromiseUtil.sleep(300);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [rows, level, syslog]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
void runRefresh();
|
||||
}, [runRefresh]);
|
||||
|
||||
const refreshRef = useRef(refresh);
|
||||
useEffect(() => {
|
||||
refreshRef.current = refresh;
|
||||
});
|
||||
|
||||
// The spinner is raised during render so the fetch effect stays side-effect
|
||||
// free until its response lands.
|
||||
const refreshKey = open ? `${rows}\u0000${level}\u0000${syslog}` : null;
|
||||
const [loadingKey, setLoadingKey] = useState<string | null>(null);
|
||||
if (refreshKey !== loadingKey) {
|
||||
setLoadingKey(refreshKey);
|
||||
if (refreshKey) setLoading(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void runRefresh();
|
||||
}, [open, runRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !autoUpdate) return;
|
||||
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [open, autoUpdate]);
|
||||
|
||||
const parsedLogs = useMemo(() => logs.map(parseLogLine), [logs]);
|
||||
|
||||
function download() {
|
||||
FileManager.downloadTextFile(logs.join('\n'), 'x-ui.log');
|
||||
}
|
||||
|
||||
const titleNode = (
|
||||
<>
|
||||
{t('pages.index.logs')}
|
||||
<SyncOutlined
|
||||
spin={loading}
|
||||
className="reload-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('refresh')}
|
||||
onClick={refresh}
|
||||
onKeyDown={activateOnKey(refresh)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
footer={null}
|
||||
width={isMobile ? '100vw' : 800}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
className={isMobile ? 'logmodal-mobile' : undefined}
|
||||
onCancel={onClose}
|
||||
title={titleNode}
|
||||
>
|
||||
<Form layout="inline" className="log-toolbar">
|
||||
<Form.Item>
|
||||
<Space.Compact>
|
||||
<Select
|
||||
value={rows}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
onChange={setRows}
|
||||
options={[
|
||||
{ value: '20', label: '20' },
|
||||
{ value: '50', label: '50' },
|
||||
{ value: '100', label: '100' },
|
||||
{ value: '500', label: '500' },
|
||||
{ value: '1000', label: '1000' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={level}
|
||||
size="small"
|
||||
style={{ minWidth: 95 }}
|
||||
onChange={setLevel}
|
||||
options={[
|
||||
{ value: 'debug', label: t('pages.index.logLevelDebug') },
|
||||
{ value: 'info', label: t('pages.index.logLevelInfo') },
|
||||
{ value: 'notice', label: t('pages.index.logLevelNotice') },
|
||||
{ value: 'warning', label: t('pages.index.logLevelWarning') },
|
||||
{ value: 'err', label: t('pages.index.logLevelError') },
|
||||
]}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={syslog} onChange={(e) => setSyslog(e.target.checked)}>
|
||||
SysLog
|
||||
</Checkbox>
|
||||
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
|
||||
{t('pages.index.autoUpdate')}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item className="download-item">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={download}
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label={t('download')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
|
||||
{parsedLogs.length === 0 ? (
|
||||
<div className="log-empty">No Record...</div>
|
||||
) : isMobile ? (
|
||||
parsedLogs.map((log, idx) => (
|
||||
<div key={idx} className="log-card">
|
||||
<div className="log-card-head">
|
||||
{log.stamp && (
|
||||
<span className="log-time">
|
||||
{log.time && <span>{log.time}</span>}
|
||||
{log.time && log.date ? ' ' : ''}
|
||||
{log.date && <span className="log-date">{log.date}</span>}
|
||||
</span>
|
||||
)}
|
||||
{log.levelText && (
|
||||
<span className={`log-level-badge ${log.levelClass}`}>{log.levelText}</span>
|
||||
)}
|
||||
</div>
|
||||
{(log.body || log.service) && (
|
||||
<div className="log-body">
|
||||
{log.service && <b>{log.service}</b>}
|
||||
{log.service && log.body ? ' ' : ''}
|
||||
{log.body && <span className="log-body-text">{log.body}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
parsedLogs.map((log, idx) => (
|
||||
<div key={idx} className="log-line">
|
||||
{log.stamp && <span className="log-stamp">{log.stamp}</span>}
|
||||
{log.stamp && log.levelText ? ' ' : ''}
|
||||
{log.levelText && (
|
||||
<span className={`log-level ${log.levelClass}`}>{log.levelText}</span>
|
||||
)}
|
||||
{(log.body || log.service) && (
|
||||
<>
|
||||
{(log.stamp || log.levelText) && <span> - </span>}
|
||||
{log.service && <b>{log.service}</b>}
|
||||
{log.service && log.body ? ' ' : ''}
|
||||
<span>{log.body}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,18 @@
|
||||
import { Fragment } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
ArrowUpOutlined,
|
||||
AreaChartOutlined,
|
||||
BarsOutlined,
|
||||
CloudDownloadOutlined,
|
||||
CloudServerOutlined,
|
||||
ControlOutlined,
|
||||
FileTextOutlined,
|
||||
PoweroffOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { CloudDownloadOutlined, CloudServerOutlined, ControlOutlined } from '@ant-design/icons';
|
||||
|
||||
import { formatPanelVersion } from '@/lib/panel-version';
|
||||
import type { Status } from '@/models/status';
|
||||
|
||||
interface OverviewActionBarProps {
|
||||
status: Status;
|
||||
isMobile: boolean;
|
||||
accessLogEnable: boolean;
|
||||
panelVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
onStopXray: () => void;
|
||||
onRestartXray: () => void;
|
||||
onOpenLogs: () => void;
|
||||
onOpenXrayLogs: () => void;
|
||||
onOpenAmneziaWGLogs: () => void;
|
||||
onOpenConfig: () => void;
|
||||
onOpenBackup: () => void;
|
||||
onOpenSystemHistory: () => void;
|
||||
onOpenXrayMetrics: () => void;
|
||||
onOpenPanelUpdate: () => void;
|
||||
onOpenVersionSwitch: () => void;
|
||||
}
|
||||
|
||||
interface BarAction {
|
||||
@@ -46,127 +23,35 @@ interface BarAction {
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
const XRAY_STATE_KEYS: Record<string, string> = {
|
||||
running: 'pages.index.xrayStatusRunning',
|
||||
stop: 'pages.index.xrayStatusStop',
|
||||
error: 'pages.index.xrayStatusError',
|
||||
};
|
||||
|
||||
export default function OverviewActionBar({
|
||||
status,
|
||||
isMobile,
|
||||
accessLogEnable,
|
||||
panelVersion,
|
||||
latestVersion,
|
||||
updateAvailable,
|
||||
onStopXray,
|
||||
onRestartXray,
|
||||
onOpenLogs,
|
||||
onOpenXrayLogs,
|
||||
onOpenAmneziaWGLogs,
|
||||
onOpenConfig,
|
||||
onOpenBackup,
|
||||
onOpenSystemHistory,
|
||||
onOpenXrayMetrics,
|
||||
onOpenPanelUpdate,
|
||||
onOpenVersionSwitch,
|
||||
}: OverviewActionBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
|
||||
const hasVersion = !!status.xray.version && status.xray.version !== 'Unknown';
|
||||
const size = isMobile ? ('small' as const) : ('middle' as const);
|
||||
|
||||
const actionGroups: BarAction[][] = [
|
||||
[
|
||||
{
|
||||
key: 'restart',
|
||||
icon: <ReloadOutlined />,
|
||||
text: t('pages.index.restartXray'),
|
||||
onClick: onRestartXray,
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
key: 'stop',
|
||||
icon: <PoweroffOutlined />,
|
||||
text: t('pages.index.stopXray'),
|
||||
onClick: onStopXray,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
|
||||
...(accessLogEnable
|
||||
? [
|
||||
{
|
||||
key: 'accessLogs',
|
||||
icon: <FileTextOutlined />,
|
||||
text: t('pages.index.accessLogs'),
|
||||
onClick: onOpenXrayLogs,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(status.amneziawg.configured
|
||||
? [
|
||||
{
|
||||
key: 'amneziawgLogs',
|
||||
icon: <ApiOutlined />,
|
||||
text: t('pages.index.amneziawgLogs'),
|
||||
onClick: onOpenAmneziaWGLogs,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'config',
|
||||
icon: <ControlOutlined />,
|
||||
text: t('pages.index.config'),
|
||||
onClick: onOpenConfig,
|
||||
},
|
||||
{
|
||||
key: 'backup',
|
||||
icon: <CloudServerOutlined />,
|
||||
text: t('pages.index.backupTitle'),
|
||||
onClick: onOpenBackup,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'history',
|
||||
icon: <AreaChartOutlined />,
|
||||
text: t('pages.index.systemHistoryTitle'),
|
||||
onClick: onOpenSystemHistory,
|
||||
},
|
||||
{
|
||||
key: 'metrics',
|
||||
icon: <ArrowUpOutlined />,
|
||||
text: t('pages.index.xrayMetricsTitle'),
|
||||
onClick: onOpenXrayMetrics,
|
||||
},
|
||||
],
|
||||
const actions: BarAction[] = [
|
||||
{
|
||||
key: 'config',
|
||||
icon: <ControlOutlined />,
|
||||
text: t('pages.index.config'),
|
||||
onClick: onOpenConfig,
|
||||
},
|
||||
{
|
||||
key: 'backup',
|
||||
icon: <CloudServerOutlined />,
|
||||
text: t('pages.index.backupTitle'),
|
||||
onClick: onOpenBackup,
|
||||
},
|
||||
];
|
||||
|
||||
const statePill = (
|
||||
<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>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ov-bar">
|
||||
{status.xray.state === 'error' && status.xray.errorMsg ? (
|
||||
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
||||
{statePill}
|
||||
</Tooltip>
|
||||
) : (
|
||||
statePill
|
||||
)}
|
||||
|
||||
{updateAvailable ? (
|
||||
<Tag
|
||||
className="ov-update-tag"
|
||||
@@ -185,24 +70,19 @@ export default function OverviewActionBar({
|
||||
)}
|
||||
|
||||
<div className="ov-bar-actions">
|
||||
{actionGroups.map((group, groupIndex) => (
|
||||
<Fragment key={group[0].key}>
|
||||
{groupIndex > 0 && <span className="ov-bar-sep" />}
|
||||
{group.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type={action.primary ? undefined : 'text'}
|
||||
color={action.primary ? 'primary' : undefined}
|
||||
variant={action.primary ? 'outlined' : undefined}
|
||||
size={size}
|
||||
icon={action.icon}
|
||||
aria-label={action.text}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{isMobile ? undefined : action.text}
|
||||
</Button>
|
||||
))}
|
||||
</Fragment>
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type={action.primary ? undefined : 'text'}
|
||||
color={action.primary ? 'primary' : undefined}
|
||||
variant={action.primary ? 'outlined' : undefined}
|
||||
size={size}
|
||||
icon={action.icon}
|
||||
aria-label={action.text}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{isMobile ? undefined : action.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,11 @@ export interface PanelUpdateInfo {
|
||||
currentCommit?: string;
|
||||
latestCommit?: string;
|
||||
updateAvailable: boolean;
|
||||
// V-series display versions (items 18/20); the stable channel's update
|
||||
// decision and labels use these when present.
|
||||
displayCurrentVersion?: string;
|
||||
displayLatestVersion?: string;
|
||||
displayUpdateAvailable?: boolean;
|
||||
}
|
||||
|
||||
interface BusyEvent {
|
||||
@@ -47,6 +52,13 @@ export default function PanelUpdateModal({
|
||||
|
||||
const isDev = info.channel === 'dev';
|
||||
|
||||
// On the stable channel the V-series display comparison decides (item
|
||||
// 20); the dev channel keeps its commit-based decision.
|
||||
const masterUpdateAvailable = isDev
|
||||
? info.updateAvailable
|
||||
: (info.displayUpdateAvailable ?? info.updateAvailable);
|
||||
const displayLatest = info.displayLatestVersion || info.latestVersion;
|
||||
|
||||
async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> {
|
||||
await PromiseUtil.sleep(5000);
|
||||
const deadline = Date.now() + 90_000;
|
||||
@@ -85,13 +97,14 @@ export default function PanelUpdateModal({
|
||||
title: t('pages.index.panelUpdateDialog'),
|
||||
content: t('pages.index.panelUpdateDialogDesc').replace(
|
||||
'#version#',
|
||||
info.latestVersion || '',
|
||||
isDev ? info.latestVersion || '' : displayLatest || '',
|
||||
),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const baseTip = t('pages.index.dontRefresh');
|
||||
const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip;
|
||||
const tipVersion = isDev ? info.latestVersion : displayLatest;
|
||||
const tip = tipVersion ? `${baseTip} (${tipVersion})` : baseTip;
|
||||
onClose();
|
||||
onBusy({ busy: true, tip });
|
||||
const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel');
|
||||
@@ -128,7 +141,7 @@ export default function PanelUpdateModal({
|
||||
<>
|
||||
{contextHolder}
|
||||
<Modal open={open} title={t('pages.index.updatePanel')} footer={null} onCancel={onClose}>
|
||||
{info.updateAvailable && (
|
||||
{masterUpdateAvailable && (
|
||||
<Alert
|
||||
type="warning"
|
||||
className="mb-12"
|
||||
@@ -166,12 +179,14 @@ export default function PanelUpdateModal({
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{info.updateAvailable ? (
|
||||
{masterUpdateAvailable ? (
|
||||
<div className="version-list-item">
|
||||
<span>
|
||||
{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}
|
||||
</span>
|
||||
<Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag>
|
||||
<Tag color="purple">
|
||||
{(isDev ? info.latestCommit : formatPanelVersion(displayLatest)) || '-'}
|
||||
</Tag>
|
||||
</div>
|
||||
) : (
|
||||
<div className="version-list-item">
|
||||
@@ -184,7 +199,7 @@ export default function PanelUpdateModal({
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!info.updateAvailable}
|
||||
disabled={!masterUpdateAvailable}
|
||||
onClick={updatePanel}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
.metric-modal-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bucket-select {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.history-tabs {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.history-chart-title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.cpu-chart-wrap {
|
||||
margin: 8px 8px 16px;
|
||||
padding: 16px 18px 18px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--ant-color-primary) 6%, transparent),
|
||||
transparent
|
||||
);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
box-shadow: 0 2px 12px var(--ant-color-fill-quaternary);
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Select, Tabs } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DeploymentUnitOutlined,
|
||||
GlobalOutlined,
|
||||
HddOutlined,
|
||||
LineChartOutlined,
|
||||
PieChartOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, SizeFormatter } from '@/utils';
|
||||
import { Sparkline } from '@/components/viz';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import type { Status } from '@/models/status';
|
||||
import './SystemHistoryModal.css';
|
||||
|
||||
interface SystemHistoryModalProps {
|
||||
open: boolean;
|
||||
status: Status;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface MetricDef {
|
||||
key: string;
|
||||
tab: string;
|
||||
tabKey?: string;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
valueMax: number | null;
|
||||
unit: string;
|
||||
stroke: string;
|
||||
key2?: string;
|
||||
stroke2?: string;
|
||||
name1?: string;
|
||||
name2?: string;
|
||||
key3?: string;
|
||||
stroke3?: string;
|
||||
name3?: string;
|
||||
}
|
||||
|
||||
const METRICS: MetricDef[] = [
|
||||
{
|
||||
key: 'cpu',
|
||||
tab: 'CPU',
|
||||
tabKey: 'pages.index.cpu',
|
||||
title: 'pages.index.historyTitleCpu',
|
||||
icon: <DashboardOutlined />,
|
||||
valueMax: 100,
|
||||
unit: '%',
|
||||
stroke: '',
|
||||
},
|
||||
{
|
||||
key: 'mem',
|
||||
tab: 'RAM',
|
||||
tabKey: 'pages.index.memory',
|
||||
title: 'pages.index.historyTitleMem',
|
||||
icon: <DatabaseOutlined />,
|
||||
valueMax: 100,
|
||||
unit: '%',
|
||||
stroke: '#7c4dff',
|
||||
key2: 'swap',
|
||||
stroke2: '#ffa940',
|
||||
name1: 'pages.index.memory',
|
||||
name2: 'pages.index.swap',
|
||||
},
|
||||
{
|
||||
key: 'netUp',
|
||||
tab: 'Bandwidth',
|
||||
tabKey: 'pages.index.historyTabBandwidth',
|
||||
title: 'pages.index.historyTitleNetwork',
|
||||
icon: <GlobalOutlined />,
|
||||
valueMax: null,
|
||||
unit: 'B/s',
|
||||
stroke: '#1890ff',
|
||||
key2: 'netDown',
|
||||
stroke2: '#13c2c2',
|
||||
name1: 'Up',
|
||||
name2: 'Down',
|
||||
},
|
||||
{
|
||||
key: 'pktUp',
|
||||
tab: 'Packets',
|
||||
tabKey: 'pages.index.historyTabPackets',
|
||||
title: 'pages.index.historyTitlePackets',
|
||||
icon: <DeploymentUnitOutlined />,
|
||||
valueMax: null,
|
||||
unit: 'pkt/s',
|
||||
stroke: '#2f54eb',
|
||||
key2: 'pktDown',
|
||||
stroke2: '#36cfc9',
|
||||
name1: 'Up',
|
||||
name2: 'Down',
|
||||
},
|
||||
{
|
||||
key: 'tcpCount',
|
||||
tab: 'Connections',
|
||||
tabKey: 'pages.index.historyTabConnections',
|
||||
title: 'pages.index.historyTitleConnections',
|
||||
icon: <ApiOutlined />,
|
||||
valueMax: null,
|
||||
unit: '',
|
||||
stroke: '#597ef7',
|
||||
key2: 'udpCount',
|
||||
stroke2: '#73d13d',
|
||||
name1: 'TCP',
|
||||
name2: 'UDP',
|
||||
},
|
||||
{
|
||||
key: 'diskRead',
|
||||
tab: 'Disk I/O',
|
||||
tabKey: 'pages.index.historyTabDisk',
|
||||
title: 'pages.index.historyTitleDisk',
|
||||
icon: <HddOutlined />,
|
||||
valueMax: null,
|
||||
unit: 'B/s',
|
||||
stroke: '#eb2f96',
|
||||
key2: 'diskWrite',
|
||||
stroke2: '#722ed1',
|
||||
name1: 'Read',
|
||||
name2: 'Write',
|
||||
},
|
||||
{
|
||||
key: 'diskUsage',
|
||||
tab: 'Disk Usage',
|
||||
tabKey: 'pages.index.historyTabDiskUsage',
|
||||
title: 'pages.index.historyTitleDiskUsage',
|
||||
icon: <PieChartOutlined />,
|
||||
valueMax: 100,
|
||||
unit: '%',
|
||||
stroke: '#13c2c2',
|
||||
},
|
||||
{
|
||||
key: 'online',
|
||||
tab: 'Online',
|
||||
tabKey: 'pages.index.historyTabOnline',
|
||||
title: 'pages.index.historyTitleOnline',
|
||||
icon: <TeamOutlined />,
|
||||
valueMax: null,
|
||||
unit: '',
|
||||
stroke: '#52c41a',
|
||||
},
|
||||
{
|
||||
key: 'load1',
|
||||
tab: 'Load',
|
||||
tabKey: 'pages.index.historyTabLoad',
|
||||
title: 'pages.index.historyTitleLoad',
|
||||
icon: <LineChartOutlined />,
|
||||
valueMax: null,
|
||||
unit: '',
|
||||
stroke: '#fa8c16',
|
||||
key2: 'load5',
|
||||
stroke2: '#f5222d',
|
||||
name1: '1m',
|
||||
name2: '5m',
|
||||
key3: 'load15',
|
||||
stroke3: '#a0d911',
|
||||
name3: '15m',
|
||||
},
|
||||
];
|
||||
|
||||
function unitFormatter(unit: string, activeKey: string): (v: number) => string {
|
||||
if (unit === 'B/s') {
|
||||
return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0)).replace(/\.\d+/, '')}/s`;
|
||||
}
|
||||
if (unit === 'pkt/s') {
|
||||
return (v) => `${Math.round(Math.max(0, Number(v) || 0)).toLocaleString()}/s`;
|
||||
}
|
||||
if (unit === '%') {
|
||||
return (v) => `${Number(v).toFixed(1)}%`;
|
||||
}
|
||||
return (v) => {
|
||||
const n = Number(v) || 0;
|
||||
if (activeKey === 'online' || activeKey === 'tcpCount' || activeKey === 'udpCount') {
|
||||
return Math.round(n).toLocaleString();
|
||||
}
|
||||
return n.toFixed(2);
|
||||
};
|
||||
}
|
||||
|
||||
function formatFullTimestamp(unixSec: number): string {
|
||||
const d = new Date(unixSec * 1000);
|
||||
const today = new Date();
|
||||
const sameDay =
|
||||
d.getFullYear() === today.getFullYear() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
d.getDate() === today.getDate();
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
const time = `${hh}:${mm}:${ss}`;
|
||||
if (sameDay) return time;
|
||||
const MM = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const DD = String(d.getDate()).padStart(2, '0');
|
||||
return `${MM}-${DD} ${time}`;
|
||||
}
|
||||
|
||||
interface HistoryChart {
|
||||
points: number[];
|
||||
points2: number[];
|
||||
points3: number[];
|
||||
labels: string[];
|
||||
timestamps: number[];
|
||||
}
|
||||
|
||||
const EMPTY_CHART: HistoryChart = {
|
||||
points: [],
|
||||
points2: [],
|
||||
points3: [],
|
||||
labels: [],
|
||||
timestamps: [],
|
||||
};
|
||||
|
||||
async function loadBucket(metric: (typeof METRICS)[number], bucket: number): Promise<HistoryChart> {
|
||||
try {
|
||||
const msg = await HttpUtil.get(`/panel/api/server/history/${metric.key}/${bucket}`);
|
||||
if (!msg?.success || !Array.isArray(msg.obj)) return EMPTY_CHART;
|
||||
const points: number[] = [];
|
||||
const labels: string[] = [];
|
||||
const timestamps: number[] = [];
|
||||
for (const p of msg.obj) {
|
||||
const d = new Date(p.t * 1000);
|
||||
const MM = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const DD = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
labels.push(
|
||||
bucket >= 2880
|
||||
? `${MM}-${DD} ${hh}:${mm}`
|
||||
: bucket >= 60
|
||||
? `${hh}:${mm}`
|
||||
: `${hh}:${mm}:${ss}`,
|
||||
);
|
||||
points.push(Number(p.v) || 0);
|
||||
timestamps.push(Number(p.t) || 0);
|
||||
}
|
||||
const fetchAligned = async (key?: string): Promise<number[]> => {
|
||||
if (!key) return [];
|
||||
const m = await HttpUtil.get(`/panel/api/server/history/${key}/${bucket}`);
|
||||
if (!m?.success || !Array.isArray(m.obj)) return [];
|
||||
const byTs = new Map<number, number>();
|
||||
for (const p of m.obj) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
|
||||
return timestamps.map((ts) => byTs.get(ts) ?? 0);
|
||||
};
|
||||
return {
|
||||
labels,
|
||||
points,
|
||||
timestamps,
|
||||
points2: await fetchAligned(metric.key2),
|
||||
points3: await fetchAligned(metric.key3),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch history bucket', e);
|
||||
return EMPTY_CHART;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SystemHistoryModal({ open, status, onClose }: SystemHistoryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [activeKey, setActiveKey] = useState('cpu');
|
||||
const [bucket, setBucket] = useState(2);
|
||||
const [{ points, points2, points3, labels, timestamps }, setChart] =
|
||||
useState<HistoryChart>(EMPTY_CHART);
|
||||
|
||||
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
|
||||
const trName = (n?: string) => (n && n.startsWith('pages.') ? t(n) : n);
|
||||
const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
|
||||
const yFormatter = useMemo(
|
||||
() => unitFormatter(activeMetric?.unit ?? '', activeKey),
|
||||
[activeMetric, activeKey],
|
||||
);
|
||||
|
||||
const tsLookup = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
m.set(labels[i], timestamps[i]);
|
||||
}
|
||||
return m;
|
||||
}, [labels, timestamps]);
|
||||
|
||||
const tooltipLabelFormatter = useCallback(
|
||||
(label: string) => {
|
||||
const ts = tsLookup.get(label);
|
||||
return ts ? formatFullTimestamp(ts) : label;
|
||||
},
|
||||
[tsLookup],
|
||||
);
|
||||
|
||||
const fetchBucket = useCallback(async () => {
|
||||
if (!activeMetric) return;
|
||||
const next = await loadBucket(activeMetric, bucket);
|
||||
setChart(next);
|
||||
}, [activeMetric, bucket]);
|
||||
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) setActiveKey('cpu');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !activeMetric) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const next = await loadBucket(activeMetric, bucket);
|
||||
if (!cancelled) setChart(next);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, activeMetric, bucket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const ms = bucket <= 30 ? 2000 : 10000;
|
||||
const id = window.setInterval(() => fetchBucket(), ms);
|
||||
return () => window.clearInterval(id);
|
||||
}, [open, bucket, fetchBucket]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
footer={null}
|
||||
width={isMobile ? '95vw' : 900}
|
||||
onCancel={onClose}
|
||||
title={
|
||||
<div className="metric-modal-title">
|
||||
<span>{t('pages.index.systemHistoryTitle')}</span>
|
||||
<Select
|
||||
value={bucket}
|
||||
size="small"
|
||||
className="bucket-select"
|
||||
onChange={setBucket}
|
||||
options={[
|
||||
{ value: 2, label: '2m' },
|
||||
{ value: 60, label: '1h' },
|
||||
{ value: 180, label: '3h' },
|
||||
{ value: 360, label: '6h' },
|
||||
{ value: 720, label: '12h' },
|
||||
{ value: 1440, label: '24h' },
|
||||
{ value: 2880, label: '2d' },
|
||||
{ value: 10080, label: '7d' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
size="small"
|
||||
className="history-tabs"
|
||||
items={METRICS.map((m) => {
|
||||
const tabLabel = m.tabKey ? t(m.tabKey) : m.tab;
|
||||
return {
|
||||
key: m.key,
|
||||
label: isMobile ? (
|
||||
<span title={tabLabel} aria-label={tabLabel}>
|
||||
{m.icon}
|
||||
</span>
|
||||
) : (
|
||||
tabLabel
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className="cpu-chart-wrap">
|
||||
{activeMetric?.title && <div className="history-chart-title">{t(activeMetric.title)}</div>}
|
||||
<Sparkline
|
||||
data={points}
|
||||
data2={activeMetric?.key2 ? points2 : undefined}
|
||||
data3={activeMetric?.key3 ? points3 : undefined}
|
||||
stroke2={activeMetric?.stroke2}
|
||||
stroke3={activeMetric?.stroke3}
|
||||
name1={trName(activeMetric?.name1)}
|
||||
name2={trName(activeMetric?.name2)}
|
||||
name3={trName(activeMetric?.name3)}
|
||||
labels={labels}
|
||||
height={260}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2.2}
|
||||
showGrid
|
||||
showAxes
|
||||
tickCountX={5}
|
||||
maxPoints={points.length || 1}
|
||||
fillOpacity={0.18}
|
||||
markerRadius={3.2}
|
||||
showTooltip
|
||||
valueMin={0}
|
||||
valueMax={activeMetric?.valueMax ?? null}
|
||||
yFormatter={yFormatter}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
extrema={{ show: !activeMetric?.key2, formatter: yFormatter }}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -29,18 +29,11 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
|
||||
<ClockCircleOutlined />
|
||||
{t('pages.index.uptime')}
|
||||
</div>
|
||||
<div className="ov-strip-split">
|
||||
<div>
|
||||
<div className="ov-strip-sub">Xray</div>
|
||||
<div className="ov-strip-value">
|
||||
{TimeFormatter.formatSecond(status.appStats.uptime)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="ov-strip-split-sep" />
|
||||
<div>
|
||||
<div className="ov-strip-sub">OS</div>
|
||||
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
|
||||
</div>
|
||||
{/* The lite master runs no local xray core, so the uptime card
|
||||
shows the OS uptime only (the Xray half always read 0s). */}
|
||||
<div>
|
||||
<div className="ov-strip-sub">OS</div>
|
||||
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,151 +0,0 @@
|
||||
.log-toolbar {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.log-toolbar .filter-item {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
|
||||
.log-toolbar .download-item {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
--log-blocked: #e04141;
|
||||
--log-proxy: #3c89e8;
|
||||
--log-divider: rgba(128, 128, 128, 0.18);
|
||||
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.log-container-mobile {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.log-card {
|
||||
border-bottom: 1px solid var(--log-divider);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.log-card:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.log-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.log-event-tag {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.log-route {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-addr {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-arrow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.log-meta-pair {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-meta-key {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
body.dark .log-container {
|
||||
--log-blocked: #ff7575;
|
||||
--log-proxy: #6aa6ee;
|
||||
--log-divider: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .log-container {
|
||||
--log-blocked: #ff8a8a;
|
||||
--log-proxy: #7fb6f1;
|
||||
--log-divider: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.xraylog-modal-mobile .ant-modal-content {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.xraylog-modal-mobile .ant-modal-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.xraylog-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xraylog-table td,
|
||||
.xraylog-table th {
|
||||
padding: 2px 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.xraylog-table .log-row-1 {
|
||||
color: var(--log-blocked);
|
||||
}
|
||||
|
||||
.xraylog-table .log-row-2 {
|
||||
color: var(--log-proxy);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
|
||||
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import './XrayLogModal.css';
|
||||
|
||||
interface XrayLogModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface XrayLogEntry {
|
||||
DateTime?: string | number;
|
||||
FromAddress?: string;
|
||||
ToAddress?: string;
|
||||
Inbound?: string;
|
||||
Outbound?: string;
|
||||
Email?: string;
|
||||
Event?: number;
|
||||
}
|
||||
|
||||
// The downloaded log is a data format people grep, so it keeps the stable
|
||||
// tokens; only what is rendered on screen follows the panel language.
|
||||
const EVENT_TOKENS: Record<number, string> = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
|
||||
|
||||
const EVENT_KEYS: Record<number, string> = {
|
||||
0: 'pages.index.accessDirect',
|
||||
1: 'pages.index.accessBlocked',
|
||||
2: 'pages.index.accessProxy',
|
||||
};
|
||||
const EVENT_COLORS: Record<number, string> = { 0: 'green', 1: 'red', 2: 'blue' };
|
||||
|
||||
function eventToken(ev?: number): string {
|
||||
return EVENT_TOKENS[ev ?? -1] ?? String(ev ?? '');
|
||||
}
|
||||
|
||||
function eventLabel(t: TFunction, ev?: number): string {
|
||||
const key = EVENT_KEYS[ev ?? -1];
|
||||
return key ? t(key) : String(ev ?? '');
|
||||
}
|
||||
|
||||
function eventColor(ev?: number): string {
|
||||
return EVENT_COLORS[ev ?? -1] ?? 'default';
|
||||
}
|
||||
|
||||
function shortTime(value?: string | number): string {
|
||||
if (!value) return '';
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
const AUTO_UPDATE_INTERVAL = 5000;
|
||||
|
||||
export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { datepicker } = useDatepicker();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [rows, setRows] = useState('20');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [showDirect, setShowDirect] = useState(true);
|
||||
const [showBlocked, setShowBlocked] = useState(true);
|
||||
const [showProxy, setShowProxy] = useState(true);
|
||||
const [autoUpdate, setAutoUpdate] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [logs, setLogs] = useState<XrayLogEntry[]>([]);
|
||||
|
||||
const orderedLogs = useMemo(() => [...logs].reverse(), [logs]);
|
||||
|
||||
const runRefresh = useCallback(async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.post<XrayLogEntry[]>(`/panel/api/server/xraylogs/${rows}`, {
|
||||
filter,
|
||||
showDirect,
|
||||
showBlocked,
|
||||
showProxy,
|
||||
});
|
||||
if (msg?.success) setLogs(msg.obj || []);
|
||||
await PromiseUtil.sleep(300);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [rows, filter, showDirect, showBlocked, showProxy]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
void runRefresh();
|
||||
}, [runRefresh]);
|
||||
|
||||
const refreshRef = useRef(refresh);
|
||||
useEffect(() => {
|
||||
refreshRef.current = refresh;
|
||||
});
|
||||
|
||||
// The spinner is raised during render so the fetch effect stays side-effect
|
||||
// free until its response lands.
|
||||
const refreshKey = open
|
||||
? `${rows}\u0000${showDirect}\u0000${showBlocked}\u0000${showProxy}`
|
||||
: null;
|
||||
const [loadingKey, setLoadingKey] = useState<string | null>(null);
|
||||
if (refreshKey !== loadingKey) {
|
||||
setLoadingKey(refreshKey);
|
||||
if (refreshKey) setLoading(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void runRefresh();
|
||||
}, [open, rows, showDirect, showBlocked, showProxy, runRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !autoUpdate) return;
|
||||
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [open, autoUpdate]);
|
||||
|
||||
function fullDate(value?: string | number): string {
|
||||
return IntlUtil.formatDate(value, datepicker);
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!Array.isArray(logs) || logs.length === 0) {
|
||||
FileManager.downloadTextFile('', 'x-ui.log');
|
||||
return;
|
||||
}
|
||||
const lines = logs
|
||||
.map((l) => {
|
||||
try {
|
||||
const dt = l.DateTime ? new Date(l.DateTime) : null;
|
||||
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
|
||||
const eventText = eventToken(l.Event);
|
||||
const emailPart = l.Email ? ` Email=${l.Email}` : '';
|
||||
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
|
||||
} catch {
|
||||
return JSON.stringify(l);
|
||||
}
|
||||
})
|
||||
.join('\n');
|
||||
FileManager.downloadTextFile(lines, 'x-ui.log');
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
footer={null}
|
||||
width={isMobile ? '100vw' : '80vw'}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
className={isMobile ? 'xraylog-modal-mobile' : undefined}
|
||||
onCancel={onClose}
|
||||
title={
|
||||
<>
|
||||
{t('pages.index.accessLogs')}
|
||||
<SyncOutlined
|
||||
spin={loading}
|
||||
className="reload-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('refresh')}
|
||||
onClick={refresh}
|
||||
onKeyDown={activateOnKey(refresh)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form layout="inline" className="log-toolbar">
|
||||
<Form.Item>
|
||||
<Select
|
||||
value={rows}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
onChange={setRows}
|
||||
options={[
|
||||
{ value: '20', label: '20' },
|
||||
{ value: '50', label: '50' },
|
||||
{ value: '100', label: '100' },
|
||||
{ value: '500', label: '500' },
|
||||
{ value: '1000', label: '1000' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('filter')} className="filter-item">
|
||||
<Input
|
||||
value={filter}
|
||||
size="small"
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter') refresh();
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Checkbox checked={showDirect} onChange={(e) => setShowDirect(e.target.checked)}>
|
||||
Direct
|
||||
</Checkbox>
|
||||
<Checkbox checked={showBlocked} onChange={(e) => setShowBlocked(e.target.checked)}>
|
||||
Blocked
|
||||
</Checkbox>
|
||||
<Checkbox checked={showProxy} onChange={(e) => setShowProxy(e.target.checked)}>
|
||||
Proxy
|
||||
</Checkbox>
|
||||
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
|
||||
{t('pages.index.autoUpdate')}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item className="download-item">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={download}
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label={t('download')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
|
||||
{orderedLogs.length === 0 ? (
|
||||
<div className="log-empty">No Record...</div>
|
||||
) : isMobile ? (
|
||||
orderedLogs.map((log, idx) => (
|
||||
<div key={idx} className="log-card">
|
||||
<div className="log-card-head">
|
||||
<span className="log-time" title={fullDate(log.DateTime)}>
|
||||
{shortTime(log.DateTime)}
|
||||
</span>
|
||||
<Tag color={eventColor(log.Event)} className="log-event-tag">
|
||||
{eventLabel(t, log.Event)}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="log-route">
|
||||
<span className="log-addr">{log.FromAddress}</span>
|
||||
<span className="log-arrow">→</span>
|
||||
<span className="log-addr">{log.ToAddress}</span>
|
||||
</div>
|
||||
<div className="log-meta">
|
||||
{log.Inbound && (
|
||||
<span className="log-meta-pair">
|
||||
<span className="log-meta-key">in</span>
|
||||
<span className="log-meta-val">{log.Inbound}</span>
|
||||
</span>
|
||||
)}
|
||||
{log.Outbound && (
|
||||
<span className="log-meta-pair">
|
||||
<span className="log-meta-key">out</span>
|
||||
<span className="log-meta-val">{log.Outbound}</span>
|
||||
</span>
|
||||
)}
|
||||
{log.Email && (
|
||||
<span className="log-meta-pair">
|
||||
<span className="log-meta-key">email</span>
|
||||
<span className="log-meta-val">{log.Email}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<table className="xraylog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Inbound</th>
|
||||
<th>Outbound</th>
|
||||
<th>Email</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orderedLogs.map((log, idx) => (
|
||||
<tr key={idx} className={`log-row-${log.Event}`}>
|
||||
<td>
|
||||
<b>{fullDate(log.DateTime)}</b>
|
||||
</td>
|
||||
<td>{log.FromAddress}</td>
|
||||
<td>{log.ToAddress}</td>
|
||||
<td>{log.Inbound}</td>
|
||||
<td>{log.Outbound}</td>
|
||||
<td>{log.Email}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
.metrics-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.obs-pane {
|
||||
padding: 4px 16px 0;
|
||||
}
|
||||
|
||||
.obs-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.obs-select {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.obs-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.obs-stamp {
|
||||
opacity: 0.7;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.obs-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 18%, transparent);
|
||||
}
|
||||
|
||||
.obs-dot.is-alive {
|
||||
background: var(--ant-color-success);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent);
|
||||
animation: obs-dot-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.obs-dot.is-dead {
|
||||
background: var(--ant-color-error);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-error) 22%, transparent);
|
||||
}
|
||||
|
||||
@keyframes obs-dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px color-mix(in srgb, var(--ant-color-success) 6%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.obs-dot.is-alive {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Modal, Select, Tabs, Tag } from 'antd';
|
||||
import {
|
||||
BlockOutlined,
|
||||
CloudServerOutlined,
|
||||
DatabaseOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
PauseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, Msg, SizeFormatter } from '@/utils';
|
||||
import { Sparkline } from '@/components/viz';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import './XrayMetricsModal.css';
|
||||
|
||||
const OBS_KEY = 'xrObs';
|
||||
|
||||
interface XrayMetricsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface MetricDef {
|
||||
key: string;
|
||||
tab: string;
|
||||
tabKey: string;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
unit: 'B' | 'ns' | 'ms' | '';
|
||||
stroke: string;
|
||||
}
|
||||
|
||||
interface XrayState {
|
||||
enabled: boolean;
|
||||
listen: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface ObservatoryTag {
|
||||
tag: string;
|
||||
alive: boolean;
|
||||
delay: number;
|
||||
lastSeenTime: number;
|
||||
lastTryTime: number;
|
||||
}
|
||||
|
||||
const METRICS: MetricDef[] = [
|
||||
{
|
||||
key: 'xrAlloc',
|
||||
tab: 'Heap',
|
||||
tabKey: 'pages.index.xrayTabHeap',
|
||||
title: 'pages.index.xrayTitleHeap',
|
||||
icon: <DatabaseOutlined />,
|
||||
unit: 'B',
|
||||
stroke: '#7c4dff',
|
||||
},
|
||||
{
|
||||
key: 'xrSys',
|
||||
tab: 'Sys',
|
||||
tabKey: 'pages.index.xrayTabSys',
|
||||
title: 'pages.index.xrayTitleSys',
|
||||
icon: <CloudServerOutlined />,
|
||||
unit: 'B',
|
||||
stroke: '#1890ff',
|
||||
},
|
||||
{
|
||||
key: 'xrHeapObjects',
|
||||
tab: 'Objects',
|
||||
tabKey: 'pages.index.xrayTabObjects',
|
||||
title: 'pages.index.xrayTitleObjects',
|
||||
icon: <BlockOutlined />,
|
||||
unit: '',
|
||||
stroke: '#13c2c2',
|
||||
},
|
||||
{
|
||||
key: 'xrNumGC',
|
||||
tab: 'GC Count',
|
||||
tabKey: 'pages.index.xrayTabGcCount',
|
||||
title: 'pages.index.xrayTitleGcCount',
|
||||
icon: <DeleteOutlined />,
|
||||
unit: '',
|
||||
stroke: '#fa8c16',
|
||||
},
|
||||
{
|
||||
key: 'xrPauseNs',
|
||||
tab: 'GC Pause',
|
||||
tabKey: 'pages.index.xrayTabGcPause',
|
||||
title: 'pages.index.xrayTitleGcPause',
|
||||
icon: <PauseCircleOutlined />,
|
||||
unit: 'ns',
|
||||
stroke: '#f5222d',
|
||||
},
|
||||
{
|
||||
key: OBS_KEY,
|
||||
tab: 'Observatory',
|
||||
tabKey: 'pages.index.xrayTabObservatory',
|
||||
title: 'pages.index.xrayTitleObservatory',
|
||||
icon: <EyeOutlined />,
|
||||
unit: 'ms',
|
||||
stroke: '#52c41a',
|
||||
},
|
||||
];
|
||||
|
||||
function unitFormatter(unit: string): (v: number) => string {
|
||||
if (unit === 'B') return (v) => SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0));
|
||||
if (unit === 'ns') {
|
||||
return (v) => {
|
||||
const n = Math.max(0, Number(v) || 0);
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(2)} ms`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} µs`;
|
||||
return `${n.toFixed(0)} ns`;
|
||||
};
|
||||
}
|
||||
if (unit === 'ms') return (v) => `${Math.round(Number(v) || 0)} ms`;
|
||||
return (v) => {
|
||||
const n = Number(v) || 0;
|
||||
return Math.round(n).toLocaleString();
|
||||
};
|
||||
}
|
||||
|
||||
function fmtTimestamp(unixSec: number): string {
|
||||
if (!unixSec) return '—';
|
||||
const d = new Date(unixSec * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${d.toLocaleDateString()} ${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
function formatFullTimestamp(unixSec: number): string {
|
||||
const d = new Date(unixSec * 1000);
|
||||
const today = new Date();
|
||||
const sameDay =
|
||||
d.getFullYear() === today.getFullYear() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
d.getDate() === today.getDate();
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
const time = `${hh}:${mm}:${ss}`;
|
||||
if (sameDay) return time;
|
||||
const MM = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const DD = String(d.getDate()).padStart(2, '0');
|
||||
return `${MM}-${DD} ${time}`;
|
||||
}
|
||||
|
||||
interface MetricsChart {
|
||||
points: number[];
|
||||
labels: string[];
|
||||
timestamps: number[];
|
||||
}
|
||||
|
||||
const EMPTY_CHART: MetricsChart = { points: [], labels: [], timestamps: [] };
|
||||
|
||||
function toChart(msg: Msg<{ t: number; v: number }[]> | null | undefined, bucket: number) {
|
||||
if (!msg?.success || !Array.isArray(msg.obj)) return EMPTY_CHART;
|
||||
const points: number[] = [];
|
||||
const labels: string[] = [];
|
||||
const timestamps: number[] = [];
|
||||
for (const p of msg.obj) {
|
||||
const d = new Date(p.t * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
labels.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
|
||||
points.push(Number(p.v) || 0);
|
||||
timestamps.push(Number(p.t) || 0);
|
||||
}
|
||||
return { points, labels, timestamps };
|
||||
}
|
||||
|
||||
async function loadHistory(url: string | null, bucket: number): Promise<MetricsChart> {
|
||||
if (!url) return EMPTY_CHART;
|
||||
try {
|
||||
return toChart(await HttpUtil.get<{ t: number; v: number }[]>(url), bucket);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch xray metrics bucket', e);
|
||||
return EMPTY_CHART;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadState(): Promise<XrayState | null> {
|
||||
try {
|
||||
const msg = await HttpUtil.get<XrayState>('/panel/api/server/xrayMetricsState');
|
||||
return msg?.success && msg.obj ? msg.obj : null;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch xray metrics state', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadObservatory(): Promise<ObservatoryTag[]> {
|
||||
try {
|
||||
const msg = await HttpUtil.get<ObservatoryTag[]>('/panel/api/server/xrayObservatory');
|
||||
return msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch observatory snapshot', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [activeKey, setActiveKey] = useState('xrAlloc');
|
||||
const [bucket, setBucket] = useState(2);
|
||||
const [{ points, labels, timestamps }, setChart] = useState<MetricsChart>(EMPTY_CHART);
|
||||
const [state, setState] = useState<XrayState>({ enabled: false, listen: '', reason: '' });
|
||||
const [obsTags, setObsTags] = useState<ObservatoryTag[]>([]);
|
||||
const [obsActiveTag, setObsActiveTag] = useState('');
|
||||
const [obsTick, setObsTick] = useState(0);
|
||||
|
||||
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
|
||||
const isObservatory = activeKey === OBS_KEY;
|
||||
const strokeColor = activeMetric?.stroke || '#008771';
|
||||
const yFormatter = useMemo(() => unitFormatter(activeMetric?.unit ?? ''), [activeMetric]);
|
||||
|
||||
const activeObsTag = obsTags.find((tg) => tg.tag === obsActiveTag) || null;
|
||||
|
||||
const tsLookup = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
m.set(labels[i], timestamps[i]);
|
||||
}
|
||||
return m;
|
||||
}, [labels, timestamps]);
|
||||
|
||||
const tooltipLabelFormatter = useCallback(
|
||||
(label: string) => {
|
||||
const ts = tsLookup.get(label);
|
||||
return ts ? formatFullTimestamp(ts) : label;
|
||||
},
|
||||
[tsLookup],
|
||||
);
|
||||
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) setActiveKey('xrAlloc');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const next = await loadState();
|
||||
if (!cancelled && next) setState(next);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// The observatory snapshot is a live view, so it re-polls; obsTick then pulls
|
||||
// the chart along with it.
|
||||
useEffect(() => {
|
||||
if (!open || !isObservatory) return;
|
||||
let cancelled = false;
|
||||
const tick = async () => {
|
||||
const tags = await loadObservatory();
|
||||
if (cancelled) return;
|
||||
setObsTags(tags);
|
||||
setObsActiveTag((prev) => (tags.find((tg) => tg.tag === prev) ? prev : tags[0]?.tag || ''));
|
||||
setObsTick((n) => n + 1);
|
||||
};
|
||||
void tick();
|
||||
const id = window.setInterval(() => void tick(), 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [open, isObservatory]);
|
||||
|
||||
const historyUrl = isObservatory
|
||||
? obsActiveTag
|
||||
? `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(obsActiveTag)}/${bucket}`
|
||||
: null
|
||||
: activeMetric
|
||||
? `/panel/api/server/xrayMetricsHistory/${activeMetric.key}/${bucket}`
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const next = await loadHistory(historyUrl, bucket);
|
||||
if (!cancelled) setChart(next);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, historyUrl, bucket, obsTick]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
footer={null}
|
||||
width={isMobile ? '95vw' : 900}
|
||||
onCancel={onClose}
|
||||
title={
|
||||
<div className="metric-modal-title">
|
||||
<span>{t('pages.index.xrayMetricsTitle')}</span>
|
||||
<Select
|
||||
value={bucket}
|
||||
size="small"
|
||||
className="bucket-select"
|
||||
onChange={setBucket}
|
||||
options={[
|
||||
{ value: 2, label: '2m' },
|
||||
{ value: 60, label: '1h' },
|
||||
{ value: 180, label: '3h' },
|
||||
{ value: 360, label: '6h' },
|
||||
{ value: 720, label: '12h' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!state.enabled && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="metrics-alert"
|
||||
title={t('pages.index.xrayMetricsDisabled')}
|
||||
description={state.reason || t('pages.index.xrayMetricsHint')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
size="small"
|
||||
className="history-tabs"
|
||||
items={METRICS.map((m) => {
|
||||
const tabLabel = m.tabKey ? t(m.tabKey) : m.tab;
|
||||
return {
|
||||
key: m.key,
|
||||
label: isMobile ? (
|
||||
<span title={tabLabel} aria-label={tabLabel}>
|
||||
{m.icon}
|
||||
</span>
|
||||
) : (
|
||||
tabLabel
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
|
||||
{isObservatory && (
|
||||
<div className="obs-pane">
|
||||
{state.enabled && obsTags.length === 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="metrics-alert"
|
||||
title={t('pages.index.xrayObservatoryEmpty')}
|
||||
description={t('pages.index.xrayObservatoryHint')}
|
||||
/>
|
||||
) : (
|
||||
<div className="obs-controls">
|
||||
<Select
|
||||
value={obsActiveTag}
|
||||
size="small"
|
||||
className="obs-select"
|
||||
placeholder={t('pages.index.xrayObservatoryTagPlaceholder')}
|
||||
onChange={setObsActiveTag}
|
||||
options={obsTags.map((tg) => ({
|
||||
value: tg.tag,
|
||||
label: (
|
||||
<>
|
||||
<span className={`obs-dot ${tg.alive ? 'is-alive' : 'is-dead'}`} />
|
||||
{tg.tag}
|
||||
</>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
|
||||
{activeObsTag && (
|
||||
<div className="obs-stats">
|
||||
<Tag color={activeObsTag.alive ? 'green' : 'red'}>
|
||||
{activeObsTag.alive
|
||||
? t('pages.index.xrayObservatoryAlive')
|
||||
: t('pages.index.xrayObservatoryDead')}
|
||||
</Tag>
|
||||
<Tag color="blue">{activeObsTag.delay} ms</Tag>
|
||||
<span className="obs-stamp">
|
||||
{t('pages.index.xrayObservatoryLastSeen')}:{' '}
|
||||
{fmtTimestamp(activeObsTag.lastSeenTime)}
|
||||
</span>
|
||||
<span className="obs-stamp">
|
||||
{t('pages.index.xrayObservatoryLastTry')}:{' '}
|
||||
{fmtTimestamp(activeObsTag.lastTryTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cpu-chart-wrap">
|
||||
{activeMetric?.title && <div className="history-chart-title">{t(activeMetric.title)}</div>}
|
||||
<Sparkline
|
||||
data={points}
|
||||
labels={labels}
|
||||
height={260}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2.2}
|
||||
showGrid
|
||||
showAxes
|
||||
tickCountX={5}
|
||||
maxPoints={points.length || 1}
|
||||
fillOpacity={0.18}
|
||||
markerRadius={3.2}
|
||||
showTooltip
|
||||
valueMin={0}
|
||||
valueMax={null}
|
||||
yFormatter={yFormatter}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
extrema={{ show: true, formatter: yFormatter }}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// Parser for the panel log viewer. Logs reach the UI in two shapes:
|
||||
//
|
||||
// - App log (SysLog off): the in-memory buffer, formatted as
|
||||
// "2006/01/02 15:04:05 LEVEL - message"
|
||||
// - SysLog (journalctl -o short): every entry is prefixed with
|
||||
// "Mon DD HH:MM:SS host ident[pid]: " before the real message, and the
|
||||
// message itself is one of several shapes depending on which subsystem
|
||||
// emitted it:
|
||||
// "INFO - mtproto: ..." go-logging (x-ui + xray)
|
||||
// "2026/06/08 19:22:22 http: ..." Go std log (net/http, runtime)
|
||||
// "[Mon Jun 8 23:56:52 UTC 2026] ERROR ..." telego bot
|
||||
// "Stopping x-ui.service - ..." systemd
|
||||
//
|
||||
// parseLogLine normalises all of these into a stamp + level + service + body so
|
||||
// the viewer renders a readable line instead of a bare timestamp.
|
||||
|
||||
export interface ParsedLog {
|
||||
date: string;
|
||||
time: string;
|
||||
stamp: string;
|
||||
levelText: string;
|
||||
levelClass: string;
|
||||
service: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
|
||||
export const LEVEL_CLASSES = [
|
||||
'level-debug',
|
||||
'level-info',
|
||||
'level-notice',
|
||||
'level-warning',
|
||||
'level-error',
|
||||
];
|
||||
|
||||
// "Mon DD HH:MM:SS host ident[pid]: <message>" — captures the journal date,
|
||||
// time, and the message that follows the syslog identifier.
|
||||
const SYSLOG_PREFIX = /^([A-Za-z]{3}\s+\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+\S+?:\s+(.*)$/;
|
||||
// Redundant Go std-log date prefix ("2006/01/02 15:04:05 ") to strip — the
|
||||
// journal already carries the timestamp.
|
||||
const GO_LOG_DATE = /^\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/;
|
||||
// telego's own line prefix: "[Mon Jan _2 15:04:05 MST 2006] LEVEL rest".
|
||||
const TELEGO = /^\[[^\]]+\]\s+([A-Z]+)\s+(.*)$/;
|
||||
// App-log format emitted by the in-memory buffer:
|
||||
// "2006/01/02 15:04:05 LEVEL - message". Only a line matching this exact shape
|
||||
// carries a structured timestamp/level; anything else (e.g. a plain notice such
|
||||
// as the Windows "Syslog is not supported" message) is kept whole as the body.
|
||||
const APP_LOG = /^(\d{4}\/\d{2}\/\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(\S+)\s+-\s+([\s\S]*)$/;
|
||||
|
||||
// splitLevelDash pulls a leading "LEVEL - " off a message, returning the level
|
||||
// and the remainder. Returns null when the message does not start with a level.
|
||||
function splitLevelDash(message: string): { level: string; rest: string } | null {
|
||||
const dash = message.indexOf(' - ');
|
||||
if (dash < 0) return null;
|
||||
const level = message.slice(0, dash).trim();
|
||||
if (LEVELS.indexOf(level) < 0) return null;
|
||||
return { level, rest: message.slice(dash + 3) };
|
||||
}
|
||||
|
||||
export function parseLogLine(line: string): ParsedLog {
|
||||
const raw = (line || '').trim();
|
||||
|
||||
let date = '';
|
||||
let time = '';
|
||||
let levelText = '';
|
||||
let body: string;
|
||||
|
||||
const sys = raw.match(SYSLOG_PREFIX);
|
||||
if (sys) {
|
||||
date = sys[1];
|
||||
time = sys[2];
|
||||
let message = sys[3];
|
||||
|
||||
const ld = splitLevelDash(message);
|
||||
if (ld) {
|
||||
// go-logging: "LEVEL - message"
|
||||
levelText = ld.level;
|
||||
body = ld.rest;
|
||||
} else {
|
||||
// Strip the redundant Go std-log date, then try to lift a level out of a
|
||||
// telego "[timestamp] LEVEL ..." line; otherwise keep the message as-is.
|
||||
message = message.replace(GO_LOG_DATE, '');
|
||||
const tg = message.match(TELEGO);
|
||||
if (tg && LEVELS.indexOf(tg[1]) >= 0) {
|
||||
levelText = tg[1];
|
||||
body = tg[2];
|
||||
} else {
|
||||
body = message;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const app = raw.match(APP_LOG);
|
||||
if (app) {
|
||||
// App-log format: "2006/01/02 15:04:05 LEVEL - body"
|
||||
date = app[1];
|
||||
time = app[2];
|
||||
levelText = app[3];
|
||||
body = app[4];
|
||||
} else {
|
||||
// Plain message with no timestamp/level — show it verbatim.
|
||||
body = raw;
|
||||
}
|
||||
}
|
||||
|
||||
const li = LEVELS.indexOf(levelText);
|
||||
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
|
||||
|
||||
let service = '';
|
||||
if (body.startsWith('XRAY:')) {
|
||||
service = 'XRAY:';
|
||||
body = body.slice('XRAY:'.length).trimStart();
|
||||
} else if (body) {
|
||||
service = 'X-UI:';
|
||||
}
|
||||
|
||||
const stamp = [date, time].filter(Boolean).join(' ');
|
||||
|
||||
return { date, time, stamp, levelText, levelClass, service, body };
|
||||
}
|
||||
@@ -179,7 +179,7 @@ export default function LoginPage() {
|
||||
) : (
|
||||
<div className="login-card">
|
||||
<div className="brand">
|
||||
<span className="brand-name">3X-UI</span>
|
||||
<span className="brand-name">L-UI</span>
|
||||
<span className="brand-accent" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="welcome">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
@@ -16,10 +16,11 @@ import {
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
|
||||
import type { Msg } from '@/utils';
|
||||
import { HttpUtil, type Msg } from '@/utils';
|
||||
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||
import { currentPanelMasterUrl } from './agentMasterUrl';
|
||||
import './NodeFormModal.css';
|
||||
|
||||
type Mode = 'add' | 'edit';
|
||||
@@ -40,6 +41,7 @@ function defaultValues(): NodeFormValues {
|
||||
id: 0,
|
||||
name: '',
|
||||
remark: '',
|
||||
nodeType: 'traditional',
|
||||
scheme: 'https',
|
||||
address: '',
|
||||
port: 2053,
|
||||
@@ -76,9 +78,25 @@ export default function NodeFormModal({
|
||||
const [fetchingInbounds, setFetchingInbounds] = useState(false);
|
||||
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
|
||||
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
||||
const [agentToken, setAgentToken] = useState<string | null>(null);
|
||||
const [agentNodeName, setAgentNodeName] = useState('');
|
||||
const [agentMasterUrl, setAgentMasterUrl] = useState('');
|
||||
// Edit mode (item 19): the stored install material for an existing agent
|
||||
// node, fetched from the master; null until loaded / for non-agent nodes.
|
||||
// Keyed by node id so a stale result from a previously edited node is
|
||||
// never shown (visibility is derived during render below).
|
||||
const [editInstall, setEditInstall] = useState<{
|
||||
nodeId: number;
|
||||
name: string;
|
||||
token: string;
|
||||
hasToken: boolean;
|
||||
} | null>(null);
|
||||
const [editMasterUrl, setEditMasterUrl] = useState('');
|
||||
const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
|
||||
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
|
||||
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
|
||||
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
|
||||
const isAgent = nodeType === 'agent';
|
||||
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
||||
|
||||
// Outbounds and balancers share one picker (like the panel-outbound selector);
|
||||
@@ -129,6 +147,39 @@ export default function NodeFormModal({
|
||||
setTestResult(null);
|
||||
}
|
||||
|
||||
// Load the stored install material when editing an agent node so the
|
||||
// full install command can be re-displayed (item 19). Nodes created
|
||||
// before tokens were stored answer hasToken=false until the agent's
|
||||
// next authenticated request backfills the token.
|
||||
const editNodeId = mode === 'edit' && isAgent ? (node?.id ?? 0) : 0;
|
||||
useEffect(() => {
|
||||
if (!open || !editNodeId) return;
|
||||
let cancelled = false;
|
||||
HttpUtil.get<{ name: string; token: string; hasToken: boolean }>(
|
||||
`/panel/api/agent/install/${editNodeId}`,
|
||||
).then((msg) => {
|
||||
if (!cancelled && msg?.success && msg.obj) {
|
||||
setEditInstall({ nodeId: editNodeId, ...msg.obj });
|
||||
setEditMasterUrl(currentPanelMasterUrl());
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, editNodeId]);
|
||||
|
||||
const shownInstall = editInstall && editInstall.nodeId === editNodeId ? editInstall : null;
|
||||
|
||||
const editInstallCmd = useMemo(() => {
|
||||
if (!shownInstall || !editMasterUrl) return '';
|
||||
const base =
|
||||
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
|
||||
` --master ${editMasterUrl}`;
|
||||
return shownInstall.hasToken
|
||||
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
|
||||
: `${base} --name ${shownInstall.name}`;
|
||||
}, [shownInstall, editMasterUrl]);
|
||||
|
||||
const title = useMemo(
|
||||
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
|
||||
[mode, t],
|
||||
@@ -138,13 +189,16 @@ export default function NodeFormModal({
|
||||
|
||||
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
|
||||
const token = values.apiToken.trim();
|
||||
const agentMode = values.nodeType === 'agent';
|
||||
const payload: Partial<NodeRecord> = {
|
||||
id: values.id || 0,
|
||||
name: values.name.trim(),
|
||||
remark: values.remark?.trim() || '',
|
||||
nodeType: values.nodeType,
|
||||
scheme: values.scheme,
|
||||
address: values.address.trim(),
|
||||
port: values.port,
|
||||
// Agent nodes have no panel address; the backend ignores these.
|
||||
address: agentMode ? 'agent' : values.address.trim(),
|
||||
port: agentMode ? 2053 : values.port,
|
||||
basePath: values.basePath.trim() || '/',
|
||||
enable: values.enable,
|
||||
allowPrivateAddress: values.allowPrivateAddress,
|
||||
@@ -217,18 +271,36 @@ export default function NodeFormModal({
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = buildPayload(result.data);
|
||||
const test = await testConnection(payload);
|
||||
const probe = test?.success ? test.obj : null;
|
||||
if (!probe || probe.status !== 'online') {
|
||||
setTestResult(
|
||||
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') },
|
||||
);
|
||||
return;
|
||||
const agentMode = result.data.nodeType === 'agent';
|
||||
// Agent nodes have no panel to probe; skip the connection test.
|
||||
if (!agentMode) {
|
||||
const test = await testConnection(payload);
|
||||
const probe = test?.success ? test.obj : null;
|
||||
if (!probe || probe.status !== 'online') {
|
||||
setTestResult(
|
||||
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') },
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTestResult(probe);
|
||||
}
|
||||
setTestResult(probe);
|
||||
const msg = await save(payload);
|
||||
if (msg?.success) {
|
||||
onOpenChange(false);
|
||||
// Backend returns { node, agentToken, nodeType } for agent nodes.
|
||||
const obj = msg.obj as unknown as
|
||||
| { agentToken?: string; node?: { id?: number } }
|
||||
| undefined;
|
||||
if (agentMode && mode === 'add' && obj?.agentToken) {
|
||||
setAgentToken(obj.agentToken);
|
||||
setAgentNodeName(result.data.name.trim());
|
||||
// Default the master URL to the address this panel is being
|
||||
// viewed through — origin AND base path. Dropping the base
|
||||
// path made the agent's registration 404. User can edit it.
|
||||
setAgentMasterUrl(currentPanelMasterUrl());
|
||||
// Keep the modal open state; the token dialog shows on top.
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -239,6 +311,13 @@ export default function NodeFormModal({
|
||||
if (!submitting) onOpenChange(false);
|
||||
}
|
||||
|
||||
// The install command is only meaningful with a master URL; an empty
|
||||
// one would produce a command that can never register, so withhold it
|
||||
// (and say why) instead of letting the user copy a dead command.
|
||||
const installCmd = agentMasterUrl
|
||||
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
@@ -255,6 +334,27 @@ export default function NodeFormModal({
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24}>
|
||||
<FormField label={t('pages.nodes.nodeType')} name="nodeType">
|
||||
<Select
|
||||
disabled={mode === 'edit'}
|
||||
options={[
|
||||
{ value: 'traditional', label: t('pages.nodes.nodeTypeTraditional') },
|
||||
{ value: 'agent', label: t('pages.nodes.nodeTypeAgent') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
{isAgent && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.agentHint')}
|
||||
/>
|
||||
)}
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
@@ -272,80 +372,134 @@ export default function NodeFormModal({
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={6}>
|
||||
<FormField
|
||||
label={t('pages.nodes.scheme')}
|
||||
name="scheme"
|
||||
onAfterChange={(value) => {
|
||||
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
|
||||
{!isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={6}>
|
||||
<FormField
|
||||
label={t('pages.nodes.scheme')}
|
||||
name="scheme"
|
||||
onAfterChange={(value) => {
|
||||
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'https', label: 'https' },
|
||||
{ value: 'http', label: 'http' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
label={t('pages.nodes.address')}
|
||||
name="address"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
|
||||
>
|
||||
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<FormField
|
||||
label={t('pages.nodes.port')}
|
||||
name="port"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
|
||||
>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{!isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.basePath')} name="basePath">
|
||||
<Input placeholder="/" />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
{isAgent && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && isAgent && shownInstall && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>{t('pages.nodes.agentInstallCommand')}</div>
|
||||
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
|
||||
<Input
|
||||
value={editMasterUrl}
|
||||
onChange={(e) => setEditMasterUrl(e.target.value.trim())}
|
||||
style={{ marginBottom: 12, fontFamily: 'monospace' }}
|
||||
/>
|
||||
{!shownInstall.hasToken && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
title={t('pages.nodes.agentTokenNotStored')}
|
||||
/>
|
||||
)}
|
||||
<Input.TextArea
|
||||
value={editInstallCmd}
|
||||
readOnly
|
||||
rows={4}
|
||||
style={{ fontFamily: 'monospace', marginBottom: 12 }}
|
||||
/>
|
||||
<Button
|
||||
disabled={!editInstallCmd}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(editInstallCmd).then(
|
||||
() => messageApi.success(t('pages.nodes.agentTokenCopied')),
|
||||
() => messageApi.error(t('pages.nodes.agentTokenCopyFailed')),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'https', label: 'https' },
|
||||
{ value: 'http', label: 'http' },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
label={t('pages.nodes.address')}
|
||||
name="address"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
|
||||
>
|
||||
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<FormField
|
||||
label={t('pages.nodes.port')}
|
||||
name="port"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
|
||||
>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
{t('pages.nodes.copyToken')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.basePath')} name="basePath">
|
||||
<Input placeholder="/" />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||
<Switch />
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.allowPrivateAddress')}
|
||||
name="allowPrivateAddress"
|
||||
valueProp="checked"
|
||||
tooltip={t('pages.nodes.allowPrivateAddressHint')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label={t('pages.nodes.allowPrivateAddress')}
|
||||
name="allowPrivateAddress"
|
||||
valueProp="checked"
|
||||
tooltip={t('pages.nodes.allowPrivateAddressHint')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('pages.nodes.tlsVerifyMode')}
|
||||
name="tlsVerifyMode"
|
||||
tooltip={t('pages.nodes.tlsVerifyModeHint')}
|
||||
>
|
||||
<Select
|
||||
disabled={scheme === 'http'}
|
||||
options={[
|
||||
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
|
||||
{ value: 'pin', label: t('pages.nodes.tlsPin') },
|
||||
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
|
||||
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.tlsVerifyMode')}
|
||||
name="tlsVerifyMode"
|
||||
tooltip={t('pages.nodes.tlsVerifyModeHint')}
|
||||
>
|
||||
<Select
|
||||
disabled={scheme === 'http'}
|
||||
options={[
|
||||
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
|
||||
{ value: 'pin', label: t('pages.nodes.tlsPin') },
|
||||
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
|
||||
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{tlsVerifyMode === 'skip' && (
|
||||
<Alert
|
||||
@@ -380,21 +534,23 @@ export default function NodeFormModal({
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label={t('pages.nodes.apiToken')}
|
||||
name="apiToken"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
|
||||
tooltip={t('pages.nodes.apiTokenHint')}
|
||||
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={
|
||||
editingWithToken
|
||||
? t('pages.nodes.apiTokenKeepHint')
|
||||
: t('pages.nodes.apiTokenPlaceholder')
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
{!isAgent && (
|
||||
<FormField
|
||||
label={t('pages.nodes.apiToken')}
|
||||
name="apiToken"
|
||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
|
||||
tooltip={t('pages.nodes.apiTokenHint')}
|
||||
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={
|
||||
editingWithToken
|
||||
? t('pages.nodes.apiTokenKeepHint')
|
||||
: t('pages.nodes.apiTokenPlaceholder')
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label={t('pages.nodes.outboundTag')}
|
||||
@@ -455,35 +611,101 @@ export default function NodeFormModal({
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="test-row">
|
||||
<Button type="default" loading={testing} onClick={onTest}>
|
||||
{t('pages.nodes.testConnection')}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className="test-result">
|
||||
{testResult.status === 'online' ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
|
||||
description={
|
||||
testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionFailed')}
|
||||
description={testResult.error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isAgent && (
|
||||
<div className="test-row">
|
||||
<Button type="default" loading={testing} onClick={onTest}>
|
||||
{t('pages.nodes.testConnection')}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className="test-result">
|
||||
{testResult.status === 'online' ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
|
||||
description={
|
||||
testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={t('pages.nodes.connectionFailed')}
|
||||
description={testResult.error}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
{/* Agent install command - shown once after creating an agent node */}
|
||||
<Modal
|
||||
open={agentToken !== null}
|
||||
title={t('pages.nodes.agentTokenTitle')}
|
||||
width={640}
|
||||
footer={[
|
||||
<Button
|
||||
key="copy"
|
||||
type="primary"
|
||||
disabled={!installCmd}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(installCmd).then(
|
||||
() => messageApi.success(t('pages.nodes.agentTokenCopied')),
|
||||
() => messageApi.error(t('pages.nodes.agentTokenCopyFailed')),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('pages.nodes.copyToken')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="close"
|
||||
onClick={() => {
|
||||
setAgentToken(null);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{t('close')}
|
||||
</Button>,
|
||||
]}
|
||||
onCancel={() => {
|
||||
setAgentToken(null);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
title={t('pages.nodes.agentTokenCreated', { name: agentNodeName })}
|
||||
/>
|
||||
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
|
||||
<Input
|
||||
value={agentMasterUrl}
|
||||
onChange={(e) => setAgentMasterUrl(e.target.value.trim())}
|
||||
style={{ marginBottom: 12, fontFamily: 'monospace' }}
|
||||
/>
|
||||
{!agentMasterUrl && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
title={t('pages.nodes.agentMasterUrlRequired')}
|
||||
/>
|
||||
)}
|
||||
<p>{t('pages.nodes.agentTokenHint')}</p>
|
||||
<Input.TextArea
|
||||
value={installCmd}
|
||||
placeholder={t('pages.nodes.agentMasterUrlRequired')}
|
||||
readOnly
|
||||
rows={4}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
<p style={{ marginTop: 12 }}>{t('pages.nodes.agentTokenInstructions')}</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,17 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Category chip hover list (same values as the inbounds page). */
|
||||
.client-email-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.client-email-list > div {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.card-history {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Button, Card, Dropdown, Modal, Space, Switch, Table, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Modal,
|
||||
Popover,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import type { BadgeProps } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
@@ -48,6 +60,45 @@ function isUpdateEligible(n: NodeRecord): boolean {
|
||||
return !!n.enable && n.status === 'online';
|
||||
}
|
||||
|
||||
// One category chip of the nodes table's clients cell. When the backend
|
||||
// sent the category's email list, hovering pops the list over (same
|
||||
// pattern as the inbounds page); without a list it keeps the plain
|
||||
// category tooltip. Chip styling is identical either way.
|
||||
function CategoryChip({
|
||||
title,
|
||||
emails,
|
||||
color,
|
||||
count,
|
||||
}: {
|
||||
title: string;
|
||||
emails?: string[];
|
||||
color?: string;
|
||||
count: number;
|
||||
}) {
|
||||
const tag = (
|
||||
<Tag color={color} className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
{count}
|
||||
</Tag>
|
||||
);
|
||||
if (emails && emails.length > 0) {
|
||||
return (
|
||||
<Popover
|
||||
title={title}
|
||||
content={
|
||||
<div className="client-email-list">
|
||||
{emails.map((e) => (
|
||||
<div key={e}>{e}</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{tag}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
return <Tooltip title={title}>{tag}</Tooltip>;
|
||||
}
|
||||
|
||||
interface NodeRow extends NodeRecord {
|
||||
url: string;
|
||||
key: string | number;
|
||||
@@ -239,7 +290,7 @@ export default function NodeList({
|
||||
{
|
||||
title: t('pages.nodes.actions'),
|
||||
align: 'center',
|
||||
width: 190,
|
||||
width: 150,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<Tooltip
|
||||
@@ -264,13 +315,23 @@ export default function NodeList({
|
||||
/>
|
||||
</Tooltip>
|
||||
{isUpdateEligible(record) && (
|
||||
<Tooltip title={t('pages.nodes.updatePanel')}>
|
||||
<Tooltip
|
||||
title={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
style={{ fontSize: 16 }}
|
||||
icon={<CloudDownloadOutlined />}
|
||||
aria-label={t('pages.nodes.updatePanel')}
|
||||
aria-label={t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
onClick={() => onUpdateNode(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -303,7 +364,7 @@ export default function NodeList({
|
||||
title: t('pages.nodes.enable'),
|
||||
dataIndex: 'enable',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
width: 64,
|
||||
render: (_value, record) =>
|
||||
record.transitive ? (
|
||||
<span style={{ opacity: 0.4 }}>—</span>
|
||||
@@ -362,17 +423,38 @@ export default function NodeList({
|
||||
</span>
|
||||
),
|
||||
dataIndex: 'url',
|
||||
width: 330,
|
||||
ellipsis: true,
|
||||
render: (_value, record) => (
|
||||
<a
|
||||
href={record.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{record.url}
|
||||
</a>
|
||||
),
|
||||
render: (_value, record) => {
|
||||
if (record.nodeType === 'agent') {
|
||||
// Agent nodes have no panel to link to: show the host IPs the
|
||||
// agent reports with its heartbeat as plain text on one line
|
||||
// (P13, layout per item 21).
|
||||
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
|
||||
return (
|
||||
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
|
||||
{ips.length > 0
|
||||
? ips.map((ip, i) => (
|
||||
<span key={ip}>
|
||||
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
|
||||
{ip}
|
||||
</span>
|
||||
))
|
||||
: '-'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={record.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={showAddress ? 'address-visible' : 'address-hidden'}
|
||||
>
|
||||
{record.url}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.status'),
|
||||
@@ -418,7 +500,13 @@ export default function NodeList({
|
||||
dataIndex: 'panelVersion',
|
||||
align: 'center',
|
||||
render: (_value, record) => {
|
||||
// Agent rows carry the A-series display version ("A1.0.0"), not
|
||||
// an upstream panel version: the update badge must never light
|
||||
// up for them (the version comparator falls back to string
|
||||
// inequality for unparseable versions, which would always
|
||||
// differ) (P13, item 18).
|
||||
const canUpdate =
|
||||
record.nodeType !== 'agent' &&
|
||||
isUpdateEligible(record) &&
|
||||
isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
|
||||
return (
|
||||
@@ -451,51 +539,42 @@ export default function NodeList({
|
||||
{
|
||||
title: t('clients'),
|
||||
align: 'center',
|
||||
width: 180,
|
||||
width: 150,
|
||||
render: (_value, record) => (
|
||||
<Space size={2}>
|
||||
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
<TeamOutlined /> {record.clientCount || 0}
|
||||
</Tag>
|
||||
{record.activeCount ? (
|
||||
<Tooltip title={t('subscription.active')}>
|
||||
<Tag
|
||||
color="green"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.activeCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('subscription.active')}
|
||||
emails={record.activeEmails}
|
||||
color="green"
|
||||
count={record.activeCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.disabledCount ? (
|
||||
<Tooltip title={t('disabled')}>
|
||||
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
|
||||
{record.disabledCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('disabled')}
|
||||
emails={record.disabledEmails}
|
||||
count={record.disabledCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.depletedCount ? (
|
||||
<Tooltip title={t('depleted')}>
|
||||
<Tag
|
||||
color="red"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.depletedCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('depleted')}
|
||||
emails={record.depletedEmails}
|
||||
color="red"
|
||||
count={record.depletedCount}
|
||||
/>
|
||||
) : null}
|
||||
{record.onlineCount ? (
|
||||
<Tooltip title={t('online')}>
|
||||
<Tag
|
||||
color="blue"
|
||||
className="client-count-tag"
|
||||
style={{ margin: 0, padding: '0 2px' }}
|
||||
>
|
||||
{record.onlineCount}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CategoryChip
|
||||
title={t('online')}
|
||||
emails={record.onlineEmails}
|
||||
color="blue"
|
||||
count={record.onlineCount}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
@@ -504,7 +583,7 @@ export default function NodeList({
|
||||
title: t('pages.nodes.latency'),
|
||||
dataIndex: 'latencyMs',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
width: 84,
|
||||
render: (_value, record) =>
|
||||
record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
|
||||
},
|
||||
@@ -512,7 +591,7 @@ export default function NodeList({
|
||||
title: t('pages.nodes.lastHeartbeat'),
|
||||
dataIndex: 'lastHeartbeat',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
width: 96,
|
||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||
},
|
||||
],
|
||||
@@ -629,7 +708,12 @@ export default function NodeList({
|
||||
key: 'update',
|
||||
label: (
|
||||
<>
|
||||
<CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}
|
||||
<CloudDownloadOutlined />{' '}
|
||||
{t(
|
||||
record.nodeType === 'agent'
|
||||
? 'pages.nodes.updateAgent'
|
||||
: 'pages.nodes.updatePanel',
|
||||
)}
|
||||
</>
|
||||
),
|
||||
onClick: () => onUpdateNode(record),
|
||||
|
||||
@@ -232,6 +232,18 @@ export default function NodesPage() {
|
||||
|
||||
const onUpdateNode = useCallback(
|
||||
(node: NodeRecord) => {
|
||||
// Agent nodes update through the master-recorded intent (item 20):
|
||||
// there is no channel to pick, so the confirm is a plain message.
|
||||
if (node.nodeType === 'agent') {
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
content: t('pages.nodes.updateAgentConfirmContent'),
|
||||
okText: t('update'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => runUpdate([node.id], false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
devRef.current = false;
|
||||
modal.confirm({
|
||||
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Default master URL offered in the agent install command.
|
||||
//
|
||||
// The agent registers against {master_url}/panel/api/agent/register, and
|
||||
// the panel itself is usually served under a base path (webBasePath, e.g.
|
||||
// "/rogue"). Prefilling the bare origin — as the form used to — produced
|
||||
// an install command whose registration 404'd, leaving the node stuck at
|
||||
// "unknown" on the panel. The address the operator is viewing the panel
|
||||
// through already contains the base path, so derive the default from it:
|
||||
// origin + window.X_UI_BASE_PATH (injected by the panel server). The
|
||||
// operator can still override it (e.g. a public address instead of a LAN
|
||||
// or tunnel one).
|
||||
export function computeDefaultMasterUrl(origin: string, basePath: string | undefined): string {
|
||||
const trimmed = (basePath || '/').replace(/\/+$/, '');
|
||||
return origin + trimmed;
|
||||
}
|
||||
|
||||
export function currentPanelMasterUrl(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return computeDefaultMasterUrl(window.location.origin, window.X_UI_BASE_PATH);
|
||||
}
|
||||
@@ -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,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,11 +25,10 @@ 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';
|
||||
import TelegramTab from './TelegramTab';
|
||||
import './SettingsPage.css';
|
||||
|
||||
interface ApiMsg {
|
||||
@@ -40,7 +39,6 @@ const tabSlugs = [
|
||||
'general',
|
||||
'security',
|
||||
'telegram',
|
||||
'email',
|
||||
'subscription',
|
||||
'subscription-formats',
|
||||
'subscription-balancers',
|
||||
@@ -77,16 +75,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 +196,9 @@ export default function SettingsPage() {
|
||||
const categoryBody = useMemo(() => {
|
||||
switch (activeSlug) {
|
||||
case 'security':
|
||||
return (
|
||||
<SecurityTab
|
||||
allSetting={allSetting}
|
||||
updateSetting={updateSetting}
|
||||
saveSetting={savePayload}
|
||||
/>
|
||||
);
|
||||
return <SecurityTab />;
|
||||
case 'telegram':
|
||||
return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'email':
|
||||
return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'subscription':
|
||||
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'subscription-formats':
|
||||
@@ -226,7 +208,7 @@ export default function SettingsPage() {
|
||||
default:
|
||||
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
}
|
||||
}, [activeSlug, allSetting, updateSetting, savePayload]);
|
||||
}, [activeSlug, allSetting, updateSetting]);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
|
||||
@@ -112,11 +112,12 @@ export default function SubscriptionGeneralTab({
|
||||
description={t('pages.settings.subPortDesc')}
|
||||
>
|
||||
<InputNumber
|
||||
value={allSetting.subPort}
|
||||
min={1}
|
||||
value={allSetting.subPort || null}
|
||||
min={0}
|
||||
max={65535}
|
||||
placeholder="2096"
|
||||
style={{ width: '100%' }}
|
||||
onChange={onNumber((v) => updateSetting({ subPort: v }))}
|
||||
onChange={onNumber((v) => updateSetting({ subPort: v }), { onClear: 0 })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<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,16 +1,3 @@
|
||||
.xray-page .restart-icon {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.xray-page .restart-result {
|
||||
max-width: 480px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xray-page .advanced-meta h4 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -61,21 +55,13 @@ export default function XrayPage() {
|
||||
setXraySetting,
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
setOutboundTestUrl,
|
||||
inboundTags,
|
||||
clientReverseTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
subscriptionTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
resetOutboundsTraffic,
|
||||
testOutbound,
|
||||
testSubscriptionOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
} = xs;
|
||||
@@ -83,9 +69,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'
|
||||
@@ -107,16 +91,6 @@ export default function XrayPage() {
|
||||
[setTemplateSettings],
|
||||
);
|
||||
|
||||
async function onTestOutbound(idx: number, mode: string) {
|
||||
const outbound = templateSettings?.outbounds?.[idx];
|
||||
if (outbound) await testOutbound(idx, outbound, mode);
|
||||
}
|
||||
|
||||
async function onTestSubscription(outbound: Record<string, unknown>, mode: string) {
|
||||
const tag = typeof outbound?.tag === 'string' ? outbound.tag : '';
|
||||
if (tag) await testSubscriptionOutbound(tag, outbound, mode);
|
||||
}
|
||||
|
||||
function onAddOutbound(outbound: Record<string, unknown>) {
|
||||
mutate((tt) => {
|
||||
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
||||
@@ -157,59 +131,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) {
|
||||
@@ -252,71 +178,22 @@ export default function XrayPage() {
|
||||
templateSettings={templateSettings}
|
||||
setTemplateSettings={setTemplateSettings}
|
||||
outboundsTraffic={outboundsTraffic}
|
||||
outboundTestStates={outboundTestStates}
|
||||
subscriptionTestStates={subscriptionTestStates}
|
||||
testingAll={testingAll}
|
||||
inboundTags={inboundTags}
|
||||
subscriptionOutbounds={subscriptionOutbounds}
|
||||
subscriptionOutboundTags={subscriptionOutboundTags}
|
||||
isMobile={isMobile}
|
||||
onResetTraffic={resetOutboundsTraffic}
|
||||
onTest={onTestOutbound}
|
||||
onTestSubscription={onTestSubscription}
|
||||
onTestAll={testAllOutbounds}
|
||||
onShowWarp={() => setWarpOpen(true)}
|
||||
onShowNord={() => setNordOpen(true)}
|
||||
onShowPia={() => setPiaOpen(true)}
|
||||
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
|
||||
templateSettings={templateSettings}
|
||||
setTemplateSettings={setTemplateSettings}
|
||||
outboundTestUrl={outboundTestUrl}
|
||||
onChangeOutboundTestUrl={setOutboundTestUrl}
|
||||
onResetDefault={resetToDefault}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
@@ -29,16 +29,12 @@ import {
|
||||
interface BasicsTabProps {
|
||||
templateSettings: XraySettingsValue | null;
|
||||
setTemplateSettings: SetTemplate;
|
||||
outboundTestUrl: string;
|
||||
onChangeOutboundTestUrl: (v: string) => void;
|
||||
onResetDefault: () => void;
|
||||
}
|
||||
|
||||
export default function BasicsTab({
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
onChangeOutboundTestUrl,
|
||||
onResetDefault,
|
||||
}: BasicsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -276,18 +272,6 @@ export default function BasicsTab({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingListItem
|
||||
title={t('pages.xray.outboundTestUrl')}
|
||||
description={t('pages.xray.outboundTestUrlDesc')}
|
||||
paddings="small"
|
||||
control={
|
||||
<Input
|
||||
value={outboundTestUrl}
|
||||
onChange={(e) => onChangeOutboundTestUrl(e.target.value)}
|
||||
placeholder="https://www.google.com/generate_204"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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,18 +0,0 @@
|
||||
import { CloudOutlined } from '@ant-design/icons';
|
||||
|
||||
interface CountryPillProps {
|
||||
flag: string;
|
||||
name: string;
|
||||
warp?: string;
|
||||
}
|
||||
|
||||
export default function CountryPill({ flag, name, warp }: CountryPillProps) {
|
||||
const isWarp = !!warp && warp.toLowerCase() !== 'off';
|
||||
return (
|
||||
<span className={isWarp ? 'country-pill warp-on' : 'country-pill'}>
|
||||
{isWarp && <CloudOutlined className="warp-cloud-icon" />}
|
||||
{flag && <span>{flag}</span>}
|
||||
<span>{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
@@ -7,127 +6,34 @@ import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
ThunderboltOutlined,
|
||||
LoadingOutlined,
|
||||
ExportOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import CountryPill from './CountryPill';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
countryFlag,
|
||||
countryName,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
showSecurity,
|
||||
testResult,
|
||||
trafficFor,
|
||||
} from './outbounds-tab-helpers';
|
||||
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
|
||||
|
||||
interface OutboundCardListProps {
|
||||
rows: OutboundRow[];
|
||||
testMode: OutboundTestMode;
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
setFirst: (idx: number) => void;
|
||||
openEdit: (idx: number) => void;
|
||||
onResetTraffic: (tag: string) => void;
|
||||
confirmDelete: (idx: number) => void;
|
||||
onTest: (index: number, mode: string) => void;
|
||||
}
|
||||
|
||||
export default function OutboundCardList({
|
||||
rows,
|
||||
testMode,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
setFirst,
|
||||
openEdit,
|
||||
onResetTraffic,
|
||||
confirmDelete,
|
||||
onTest,
|
||||
}: OutboundCardListProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showEgressIp, setShowEgressIp] = useState<Record<string, boolean>>({});
|
||||
|
||||
const setCardEgressVisible = (key: string, visible: boolean) => {
|
||||
setShowEgressIp((prev) => ({ ...prev, [key]: visible }));
|
||||
};
|
||||
|
||||
const renderEgress = (testKey: number, rowKey: string) => {
|
||||
const result = testResult(outboundTestStates, testKey);
|
||||
const egress = result?.egress;
|
||||
const isEgressVisible = !!showEgressIp[rowKey];
|
||||
const flag = countryFlag(egress?.country);
|
||||
const name = countryName(egress?.country, i18n.language);
|
||||
const addresses = [
|
||||
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
|
||||
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
|
||||
].filter((item): item is { label: string; value: string } => Boolean(item));
|
||||
|
||||
if (!egress || (addresses.length === 0 && !egress.country)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-egress">
|
||||
<div className="card-egress-row">
|
||||
<span>{t('pages.xray.outbound.egress')}:</span>
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{isEgressVisible ? (
|
||||
<EyeOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setCardEgressVisible(rowKey, false)}
|
||||
onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, false))}
|
||||
/>
|
||||
) : (
|
||||
<EyeInvisibleOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setCardEgressVisible(rowKey, true)}
|
||||
onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, true))}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
{egress.country && (
|
||||
<CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
|
||||
)}
|
||||
</div>
|
||||
{addresses.map((addr) => (
|
||||
<Tooltip key={addr.label} title={addr.value}>
|
||||
<div className="card-egress-row">
|
||||
<span className="egress-family">{addr.label}:</span>
|
||||
<span
|
||||
className={
|
||||
isEgressVisible ? 'address-visible egress-ip' : 'address-hidden egress-ip'
|
||||
}
|
||||
>
|
||||
{addr.value}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
@@ -219,7 +125,6 @@ export default function OutboundCardList({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderEgress(record.key, String(record.key))}
|
||||
<div className="card-foot">
|
||||
<span className="traffic-up">
|
||||
↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}
|
||||
@@ -228,23 +133,6 @@ export default function OutboundCardList({
|
||||
<span className="traffic-down">
|
||||
↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}
|
||||
</span>
|
||||
<span className="card-test">
|
||||
{testResult(outboundTestStates, record.key) ? (
|
||||
<TestResultPopover result={testResult(outboundTestStates, record.key)!} />
|
||||
) : isTesting(outboundTestStates, record.key) ? (
|
||||
<LoadingOutlined />
|
||||
) : null}
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size="small"
|
||||
loading={isTesting(outboundTestStates, record.key)}
|
||||
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('check')}
|
||||
onClick={() => onTest(record.key, testMode)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -75,51 +75,8 @@
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.egress-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.egress-ip {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.egress-stack {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.egress-address {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.egress-family {
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.country-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
line-height: 20px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.country-pill.warp-on {
|
||||
border-color: #f48120;
|
||||
}
|
||||
|
||||
.warp-cloud-icon {
|
||||
width: 1em;
|
||||
@@ -185,30 +142,6 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-egress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card-egress-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-test {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.traffic-up {
|
||||
color: #008771;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Row,
|
||||
Space,
|
||||
Switch,
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
ApiOutlined,
|
||||
MoreOutlined,
|
||||
RetweetOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
@@ -47,13 +45,7 @@ import { propagateOutboundTagRename } from '../basics/helpers';
|
||||
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
|
||||
import DeletionImpactList from '../DeletionImpactList';
|
||||
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
||||
import type {
|
||||
XraySettingsValue,
|
||||
SetTemplate,
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
import type { XraySettingsValue, SetTemplate, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import './OutboundsTab.css';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
@@ -82,17 +74,11 @@ interface OutboundsTabProps {
|
||||
templateSettings: XraySettingsValue | null;
|
||||
setTemplateSettings: SetTemplate;
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
subscriptionTestStates: Record<string, OutboundTestState>;
|
||||
testingAll: boolean;
|
||||
inboundTags: string[];
|
||||
subscriptionOutbounds?: unknown[];
|
||||
subscriptionOutboundTags?: string[];
|
||||
isMobile: boolean;
|
||||
onResetTraffic: (tag: string) => void;
|
||||
onTest: (index: number, mode: string) => void;
|
||||
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
|
||||
onTestAll: (mode: string) => void;
|
||||
onShowWarp: () => void;
|
||||
onShowNord: () => void;
|
||||
onShowPia: () => void;
|
||||
@@ -103,17 +89,11 @@ export default function OutboundsTab({
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
subscriptionTestStates,
|
||||
testingAll,
|
||||
inboundTags: _inboundTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
isMobile,
|
||||
onResetTraffic,
|
||||
onTest,
|
||||
onTestSubscription,
|
||||
onTestAll,
|
||||
onShowWarp,
|
||||
onShowNord,
|
||||
onShowPia,
|
||||
@@ -122,7 +102,6 @@ export default function OutboundsTab({
|
||||
const { t } = useTranslation();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [testMode, setTestMode] = useState<OutboundTestMode>('tcp');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
@@ -519,17 +498,14 @@ export default function OutboundsTab({
|
||||
}
|
||||
|
||||
const columns = useOutboundColumns({
|
||||
testMode,
|
||||
rows,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
openEdit,
|
||||
setFirst,
|
||||
moveUp,
|
||||
moveDown,
|
||||
confirmDelete,
|
||||
onResetTraffic,
|
||||
onTest,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -581,26 +557,6 @@ export default function OutboundsTab({
|
||||
</Col>
|
||||
<Col xs={24} sm={12} className="toolbar-right">
|
||||
<Space size="small" wrap>
|
||||
<Tooltip title={t('pages.xray.outbound.testModeTooltip')}>
|
||||
<Radio.Group
|
||||
value={testMode}
|
||||
onChange={(e) => setTestMode(e.target.value)}
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="tcp">TCP</Radio.Button>
|
||||
<Radio.Button value="http">HTTP</Radio.Button>
|
||||
<Radio.Button value="real">{t('pages.xray.outbound.modeRealDelay')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={testingAll}
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => onTestAll(testMode)}
|
||||
>
|
||||
{!isMobile && t('pages.xray.outbound.testAll')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
placement="topRight"
|
||||
okText={t('reset')}
|
||||
@@ -617,14 +573,11 @@ export default function OutboundsTab({
|
||||
{isMobile ? (
|
||||
<OutboundCardList
|
||||
rows={rows}
|
||||
testMode={testMode}
|
||||
outboundsTraffic={outboundsTraffic}
|
||||
outboundTestStates={outboundTestStates}
|
||||
setFirst={setFirst}
|
||||
openEdit={openEdit}
|
||||
onResetTraffic={onResetTraffic}
|
||||
confirmDelete={confirmDelete}
|
||||
onTest={onTest}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
@@ -675,10 +628,7 @@ export default function OutboundsTab({
|
||||
<SubscriptionOutbounds
|
||||
subscriptionOutbounds={subscriptionOutbounds}
|
||||
outboundsTraffic={outboundsTraffic}
|
||||
subscriptionTestStates={subscriptionTestStates}
|
||||
testMode={testMode}
|
||||
isMobile={isMobile}
|
||||
onTestSubscription={onTestSubscription}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
@@ -1,49 +1,28 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Table, Tag, Tooltip } from 'antd';
|
||||
import { ThunderboltOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { Table, Tag, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
effectiveTestMode,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
showSecurity,
|
||||
testModeLabel,
|
||||
testResult,
|
||||
trafficFor,
|
||||
} from './outbounds-tab-helpers';
|
||||
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
|
||||
|
||||
interface SubscriptionOutboundsProps {
|
||||
subscriptionOutbounds: unknown[];
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
subscriptionTestStates: Record<string, OutboundTestState>;
|
||||
testMode: OutboundTestMode;
|
||||
isMobile: boolean;
|
||||
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
|
||||
}
|
||||
|
||||
// Read-only view of outbounds imported from active subscriptions. They are not
|
||||
// part of the editable template (so no edit/delete/move), but traffic is matched
|
||||
// by tag and they can be latency-tested via the same backend endpoint.
|
||||
// by tag.
|
||||
export default function SubscriptionOutbounds({
|
||||
subscriptionOutbounds,
|
||||
outboundsTraffic,
|
||||
subscriptionTestStates,
|
||||
testMode,
|
||||
isMobile,
|
||||
onTestSubscription,
|
||||
}: SubscriptionOutboundsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -104,36 +83,6 @@ export default function SubscriptionOutbounds({
|
||||
);
|
||||
};
|
||||
|
||||
const latencyCell = (record: OutboundRow) => {
|
||||
const key = record.tag || '';
|
||||
const r = testResult(subscriptionTestStates, key);
|
||||
if (!r)
|
||||
return isTesting(subscriptionTestStates, key) ? (
|
||||
<LoadingOutlined />
|
||||
) : (
|
||||
<span className="empty">—</span>
|
||||
);
|
||||
return <TestResultPopover result={r} />;
|
||||
};
|
||||
|
||||
const testButton = (record: OutboundRow) => {
|
||||
const key = record.tag || '';
|
||||
return (
|
||||
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
|
||||
<Button
|
||||
aria-label={t('check')}
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size={isMobile ? 'small' : undefined}
|
||||
loading={isTesting(subscriptionTestStates, key)}
|
||||
disabled={!record.tag || isUntestable(record) || isTesting(subscriptionTestStates, key)}
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const header = (
|
||||
<div className="subscription-outbounds-head">
|
||||
<div className="subscription-outbounds-title">
|
||||
@@ -154,13 +103,9 @@ export default function SubscriptionOutbounds({
|
||||
<span className="card-num">{index + 1}</span>
|
||||
{identityCell(record)}
|
||||
</div>
|
||||
{testButton(record)}
|
||||
</div>
|
||||
{outboundAddresses(record).length > 0 && addressCell(record)}
|
||||
<div className="card-foot">
|
||||
{trafficCell(record)}
|
||||
<span className="card-test">{latencyCell(record)}</span>
|
||||
</div>
|
||||
<div className="card-foot">{trafficCell(record)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -194,20 +139,6 @@ export default function SubscriptionOutbounds({
|
||||
width: 200,
|
||||
render: (_v, record) => trafficCell(record),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.latency'),
|
||||
key: 'testResult',
|
||||
align: 'left',
|
||||
width: 140,
|
||||
render: (_v, record) => latencyCell(record),
|
||||
},
|
||||
{
|
||||
title: t('check'),
|
||||
key: 'test',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (_v, record) => testButton(record),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Popover } from 'antd';
|
||||
import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
|
||||
import type { OutboundTestResult } from '@/hooks/useXraySetting';
|
||||
|
||||
import { testModeLabel } from './outbounds-tab-helpers';
|
||||
|
||||
interface TestResultPopoverProps {
|
||||
result: OutboundTestResult;
|
||||
// Custom trigger element; defaults to the ok/fail latency pill.
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// Latency pill + detail popover for an outbound test result: per-endpoint
|
||||
// dial outcomes for TCP probes, HTTP status and the timing breakdown for
|
||||
// HTTP probes.
|
||||
export default function TestResultPopover({ result: r, children }: TestResultPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const breakdown: Array<{ key: string; label: string; value: string }> = [];
|
||||
if (typeof r.httpStatus === 'number') {
|
||||
breakdown.push({
|
||||
key: 'status',
|
||||
label: t('pages.xray.outbound.httpStatus'),
|
||||
value: String(r.httpStatus),
|
||||
});
|
||||
}
|
||||
if (typeof r.connectMs === 'number') {
|
||||
breakdown.push({
|
||||
key: 'connect',
|
||||
label: t('pages.xray.outbound.breakdownConnect'),
|
||||
value: `${r.connectMs} ms`,
|
||||
});
|
||||
}
|
||||
if (typeof r.tlsMs === 'number') {
|
||||
breakdown.push({
|
||||
key: 'tls',
|
||||
label: t('pages.xray.outbound.breakdownTls'),
|
||||
value: `${r.tlsMs} ms`,
|
||||
});
|
||||
}
|
||||
if (typeof r.ttfbMs === 'number') {
|
||||
breakdown.push({
|
||||
key: 'ttfb',
|
||||
label: t('pages.xray.outbound.breakdownTtfb'),
|
||||
value: `${r.ttfbMs} ms`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="topLeft"
|
||||
rootClassName="outbound-test-popover"
|
||||
content={
|
||||
<div className="timing-breakdown">
|
||||
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
||||
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
||||
{r.mode && <span className="mode-badge">{testModeLabel(String(r.mode), t)}</span>}
|
||||
</div>
|
||||
{(r.endpoints || []).map((ep) => (
|
||||
<div key={ep.address} className="endpoint-row">
|
||||
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
||||
<span className="ep-addr">{ep.address}</span>
|
||||
<span className="ep-meta">
|
||||
{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{breakdown.map((row) => (
|
||||
<div key={row.key} className="breakdown-row">
|
||||
<span className="bd-label">{row.label}</span>
|
||||
<span className="bd-value">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children ?? (
|
||||
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
||||
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
||||
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
||||
</span>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import { isUdpOutbound } from '@/hooks/useXraySetting';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
|
||||
@@ -54,33 +47,10 @@ export function outboundAddresses(o: OutboundRow): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function isUntestable(o: OutboundRow): boolean {
|
||||
if (!o) return true;
|
||||
if (
|
||||
o.protocol === Protocols.Blackhole ||
|
||||
o.protocol === Protocols.Loopback ||
|
||||
o.tag === 'blocked'
|
||||
)
|
||||
return true;
|
||||
// freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and
|
||||
// an HTTP probe would only measure the host's own direct reachability, so
|
||||
// they're untestable in every mode.
|
||||
if (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function showSecurity(security?: string): boolean {
|
||||
return security === 'tls' || security === 'reality';
|
||||
}
|
||||
|
||||
export function effectiveTestMode(o: unknown, mode: OutboundTestMode): OutboundTestMode {
|
||||
return mode === 'tcp' && isUdpOutbound(o) ? 'http' : mode;
|
||||
}
|
||||
|
||||
export function testModeLabel(mode: string, t: TFunction): string {
|
||||
return mode === 'real' ? t('pages.xray.outbound.modeRealDelay') : mode.toUpperCase();
|
||||
}
|
||||
|
||||
export function trafficFor(
|
||||
outboundsTraffic: OutboundTrafficRow[],
|
||||
o: OutboundRow,
|
||||
@@ -106,17 +76,3 @@ export function countryName(country?: string, locale?: string): string {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTesting<K extends string | number>(
|
||||
states: Record<K, OutboundTestState>,
|
||||
idx: K,
|
||||
): boolean {
|
||||
return !!states?.[idx]?.testing;
|
||||
}
|
||||
|
||||
export function testResult<K extends string | number>(
|
||||
states: Record<K, OutboundTestState>,
|
||||
idx: K,
|
||||
) {
|
||||
return states?.[idx]?.result || null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
@@ -7,69 +7,40 @@ import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
ThunderboltOutlined,
|
||||
LoadingOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import CountryPill from './CountryPill';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
effectiveTestMode,
|
||||
countryFlag,
|
||||
countryName,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
showSecurity,
|
||||
testModeLabel,
|
||||
testResult,
|
||||
trafficFor,
|
||||
} from './outbounds-tab-helpers';
|
||||
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
|
||||
|
||||
interface OutboundColumnsParams {
|
||||
testMode: OutboundTestMode;
|
||||
rows: OutboundRow[];
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
openEdit: (idx: number) => void;
|
||||
setFirst: (idx: number) => void;
|
||||
moveUp: (idx: number) => void;
|
||||
moveDown: (idx: number) => void;
|
||||
confirmDelete: (idx: number) => void;
|
||||
onResetTraffic: (tag: string) => void;
|
||||
onTest: (index: number, mode: string) => void;
|
||||
}
|
||||
|
||||
export function useOutboundColumns({
|
||||
testMode,
|
||||
rows,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
openEdit,
|
||||
setFirst,
|
||||
moveUp,
|
||||
moveDown,
|
||||
confirmDelete,
|
||||
onResetTraffic,
|
||||
onTest,
|
||||
}: OutboundColumnsParams): ColumnsType<OutboundRow> {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showEgressIp, setShowEgressIp] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -204,94 +175,6 @@ export function useOutboundColumns({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<span className="egress-header">
|
||||
{t('pages.xray.outbound.egress')}
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showEgressIp ? (
|
||||
<EyeOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setShowEgressIp(false)}
|
||||
onKeyDown={activateOnKey(() => setShowEgressIp(false))}
|
||||
/>
|
||||
) : (
|
||||
<EyeInvisibleOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setShowEgressIp(true)}
|
||||
onKeyDown={activateOnKey(() => setShowEgressIp(true))}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
key: 'egress',
|
||||
align: 'left',
|
||||
width: 210,
|
||||
render: (_v, record) => {
|
||||
const egress = testResult(outboundTestStates, record.key)?.egress;
|
||||
const addresses = [
|
||||
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
|
||||
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
|
||||
].filter((item): item is { label: string; value: string } => Boolean(item));
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<Tooltip title={t('pages.xray.outbound.egressHint')}>
|
||||
<span className="empty">—</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="egress-stack">
|
||||
{addresses.map((addr) => (
|
||||
<Tooltip key={addr.label} title={addr.value}>
|
||||
<span className="egress-address">
|
||||
<span className="egress-family">{addr.label}</span>
|
||||
<span
|
||||
className={
|
||||
showEgressIp ? 'address-visible egress-ip' : 'address-hidden egress-ip'
|
||||
}
|
||||
>
|
||||
{addr.value}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.xray.outbound.country'),
|
||||
key: 'egressCountry',
|
||||
align: 'left',
|
||||
width: 160,
|
||||
render: (_v, record) => {
|
||||
const egress = testResult(outboundTestStates, record.key)?.egress;
|
||||
if (!egress?.country) {
|
||||
return (
|
||||
<Tooltip title={t('pages.xray.outbound.egressHint')}>
|
||||
<span className="empty">—</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const flag = countryFlag(egress.country);
|
||||
const name = countryName(egress.country, i18n.language);
|
||||
return (
|
||||
<Tooltip
|
||||
title={egress.warp ? `Cloudflare trace · WARP ${egress.warp}` : 'Cloudflare trace'}
|
||||
>
|
||||
<CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.traffic'),
|
||||
key: 'traffic',
|
||||
@@ -308,45 +191,8 @@ export function useOutboundColumns({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.latency'),
|
||||
key: 'testResult',
|
||||
align: 'left',
|
||||
width: 140,
|
||||
render: (_v, record) => {
|
||||
const r = testResult(outboundTestStates, record.key);
|
||||
if (!r)
|
||||
return isTesting(outboundTestStates, record.key) ? (
|
||||
<LoadingOutlined />
|
||||
) : (
|
||||
<span className="empty">—</span>
|
||||
);
|
||||
return <TestResultPopover result={r} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('check'),
|
||||
key: 'test',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (_v, record) => (
|
||||
<Tooltip
|
||||
title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
loading={isTesting(outboundTestStates, record.key)}
|
||||
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('check')}
|
||||
onClick={() => onTest(record.key, testMode)}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[t, i18n.language, testMode, rows, outboundTestStates, outboundsTraffic, showEgressIp],
|
||||
[t, rows, outboundsTraffic],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Col, Input, InputNumber, Row, Select, Space, Tag } from 'antd';
|
||||
import { AimOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import { buildRemarkByTag, formatInboundTag } from './helpers';
|
||||
|
||||
interface RouteTesterProps {
|
||||
inboundTags: string[];
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
// Mirror of the /xray/routeTest response (RoutingService.TestRoute).
|
||||
interface RouteTestResult {
|
||||
matched: boolean;
|
||||
outboundTag: string;
|
||||
groupTags?: string[];
|
||||
}
|
||||
|
||||
const PROTOCOL_OPTIONS = ['http', 'tls', 'quic', 'bittorrent'].map((p) => ({ label: p, value: p }));
|
||||
|
||||
export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: inboundOptions } = useInboundOptions();
|
||||
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
|
||||
const [dest, setDest] = useState('');
|
||||
const [port, setPort] = useState<number | null>(443);
|
||||
const [network, setNetwork] = useState('tcp');
|
||||
const [inboundTag, setInboundTag] = useState<string | undefined>(undefined);
|
||||
const [protocol, setProtocol] = useState<string | undefined>(undefined);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [result, setResult] = useState<RouteTestResult | null>(null);
|
||||
|
||||
async function run() {
|
||||
const value = dest.trim();
|
||||
if (!value) return;
|
||||
// Domains never contain ':' and a pure dotted-quad is an IPv4 address;
|
||||
// everything else is treated as a domain.
|
||||
const isIp = /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(':');
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/routeTest', {
|
||||
domain: isIp ? '' : value,
|
||||
ip: isIp ? value : '',
|
||||
port: port ?? 0,
|
||||
network,
|
||||
inboundTag: inboundTag || '',
|
||||
protocol: protocol || '',
|
||||
});
|
||||
if (msg?.success && msg.obj && typeof msg.obj === 'object') {
|
||||
setResult(msg.obj as RouteTestResult);
|
||||
}
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const fieldSpan = isMobile ? 24 : undefined;
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Alert type="info" showIcon title={t('pages.xray.routeTesterDesc')} />
|
||||
<Row gutter={[8, 8]} align="bottom">
|
||||
<Col xs={fieldSpan} sm={7}>
|
||||
<Input
|
||||
aria-label={t('pages.xray.routeTesterDest')}
|
||||
placeholder={t('pages.xray.routeTesterDest')}
|
||||
value={dest}
|
||||
onChange={(e) => setDest(e.target.value)}
|
||||
onPressEnter={run}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={3}>
|
||||
<InputNumber
|
||||
aria-label={t('pages.xray.routeTesterPort')}
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
max={65535}
|
||||
placeholder={t('pages.xray.routeTesterPort')}
|
||||
value={port}
|
||||
onChange={(v) => setPort(v)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={3}>
|
||||
<Select
|
||||
aria-label={t('pages.inbounds.network')}
|
||||
style={{ width: '100%' }}
|
||||
value={network}
|
||||
onChange={setNetwork}
|
||||
options={[
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={4}>
|
||||
<Select
|
||||
aria-label={t('pages.xray.routeTesterInbound')}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('pages.xray.routeTesterInbound')}
|
||||
allowClear
|
||||
value={inboundTag}
|
||||
onChange={setInboundTag}
|
||||
options={inboundTags
|
||||
.filter(Boolean)
|
||||
.map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={4}>
|
||||
<Select
|
||||
aria-label={t('pages.xray.routeTesterProtocol')}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('pages.xray.routeTesterProtocol')}
|
||||
allowClear
|
||||
value={protocol}
|
||||
onChange={setProtocol}
|
||||
options={PROTOCOL_OPTIONS}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={fieldSpan} sm={3}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<AimOutlined />}
|
||||
loading={testing}
|
||||
disabled={!dest.trim()}
|
||||
onClick={run}
|
||||
block
|
||||
>
|
||||
{t('pages.xray.routeTesterTest')}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{result &&
|
||||
(result.matched ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
title={
|
||||
<Space wrap>
|
||||
<span>{t('pages.xray.routeTesterMatchedOutbound')}:</span>
|
||||
<Tag color="blue">{result.outboundTag || '—'}</Tag>
|
||||
{(result.groupTags || []).length > 0 && (
|
||||
<>
|
||||
<span>{t('pages.xray.routeTesterViaBalancer')}:</span>
|
||||
{(result.groupTags || []).map((tag) => (
|
||||
<Tag key={tag} color="orange">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="warning" showIcon title={t('pages.xray.routeTesterDefaultOutbound')} />
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
|
||||
import {
|
||||
AimOutlined,
|
||||
ControlOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
@@ -16,7 +15,6 @@ import PromptModal from '@/components/feedback/PromptModal';
|
||||
import TextModal from '@/components/feedback/TextModal';
|
||||
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
||||
import RoutingBasic from './RoutingBasic';
|
||||
import RouteTester from './RouteTester';
|
||||
import RuleFormModal from './RuleFormModal';
|
||||
import type { RoutingRule } from './RuleFormModal';
|
||||
import RuleCardList from './RuleCardList';
|
||||
@@ -430,11 +428,6 @@ export default function RoutingTab({
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tester',
|
||||
label: catTabLabel(<AimOutlined />, t('pages.xray.routeTester'), isMobile),
|
||||
children: <RouteTester inboundTags={inboundTagOptions} isMobile={isMobile} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<RuleFormModal
|
||||
|
||||
@@ -7,12 +7,10 @@ import PanelLayout from '@/layouts/PanelLayout';
|
||||
const IndexPage = lazy(() => import('@/pages/index/IndexPage'));
|
||||
const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
|
||||
const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
|
||||
const GroupsPage = lazy(() => import('@/pages/groups/GroupsPage'));
|
||||
const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
|
||||
const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
|
||||
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
|
||||
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
|
||||
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
|
||||
|
||||
function withSuspense(node: React.ReactNode) {
|
||||
return (
|
||||
@@ -43,14 +41,12 @@ const routes: RouteObject[] = [
|
||||
{ index: true, element: withSuspense(<IndexPage />) },
|
||||
{ path: 'inbounds', element: withSuspense(<InboundsPage />) },
|
||||
{ path: 'clients', element: withSuspense(<ClientsPage />) },
|
||||
{ path: 'groups', element: withSuspense(<GroupsPage />) },
|
||||
{ path: 'nodes', element: withSuspense(<NodesPage />) },
|
||||
{ path: 'hosts', element: withSuspense(<HostsPage />) },
|
||||
{ path: 'settings', element: withSuspense(<SettingsPage />) },
|
||||
{ path: 'xray', element: withSuspense(<XrayPage />) },
|
||||
{ path: 'outbound', element: withSuspense(<XrayPage />) },
|
||||
{ path: 'routing', element: withSuspense(<XrayPage />) },
|
||||
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -259,28 +259,6 @@ export const ActiveInboundsByNodeSchema = z
|
||||
.nullable()
|
||||
.transform((v) => v ?? {});
|
||||
|
||||
export const GroupSummarySchema = z.object({
|
||||
name: z.string(),
|
||||
clientCount: z.number(),
|
||||
trafficUsed: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
up: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
down: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((v) => v ?? 0),
|
||||
});
|
||||
|
||||
export const GroupSummaryListSchema = z
|
||||
.array(GroupSummarySchema)
|
||||
.nullable()
|
||||
.transform((v) => v ?? []);
|
||||
|
||||
export function hasForbiddenClientChars(value: string): boolean {
|
||||
if (value.includes('/') || value.includes('\\') || value.includes(' ')) return true;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
@@ -373,4 +351,3 @@ export type BulkDetachResult = z.infer<typeof BulkDetachResultSchema>;
|
||||
export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>;
|
||||
export type ClientBulkAdjustFormValues = z.infer<typeof ClientBulkAdjustFormSchema>;
|
||||
export type ClientFormValues = z.infer<typeof ClientFormSchema>;
|
||||
export type GroupSummary = z.infer<typeof GroupSummarySchema>;
|
||||
|
||||
@@ -14,6 +14,22 @@ export type TrafficReset = z.infer<typeof TrafficResetSchema>;
|
||||
export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']);
|
||||
export type ShareAddrStrategy = z.infer<typeof ShareAddrStrategySchema>;
|
||||
|
||||
// Per-inbound forwarding form (the panel-side 「转发」 section). Persisted on
|
||||
// the inbound row as a JSON string; the backend compiles it into the entry
|
||||
// agent's egress at config-pull time. mode 'direct' (or an absent spec)
|
||||
// means no forwarding.
|
||||
export const ForwardingSpecSchema = z.object({
|
||||
mode: z.enum(['direct', 'forward']).default('direct'),
|
||||
landing: z.enum(['managed', 'manual']).default('managed'),
|
||||
targetInboundId: z.number().int().default(0),
|
||||
address: z.string().default(''),
|
||||
port: z.number().int().min(0).max(65535).default(0),
|
||||
protocol: z.enum(['socks5', 'http']).default('socks5'),
|
||||
username: z.string().default(''),
|
||||
password: z.string().default(''),
|
||||
});
|
||||
export type ForwardingSpec = z.infer<typeof ForwardingSpecSchema>;
|
||||
|
||||
// Db-side fields layered on top of the xray slice. These mirror the
|
||||
// DBInbound model — they live in the SQL row, not in xray's config.
|
||||
export const InboundDbFieldsSchema = z.object({
|
||||
@@ -24,6 +40,16 @@ export const InboundDbFieldsSchema = z.object({
|
||||
trafficResetDay: z.number().int().min(1).max(31).default(1),
|
||||
lastTrafficResetTime: z.number().int().default(0),
|
||||
nodeId: z.number().int().nullable().optional(),
|
||||
forwarding: ForwardingSpecSchema.default({
|
||||
mode: 'direct',
|
||||
landing: 'managed',
|
||||
targetInboundId: 0,
|
||||
address: '',
|
||||
port: 0,
|
||||
protocol: 'socks5',
|
||||
username: '',
|
||||
password: '',
|
||||
}),
|
||||
shareAddrStrategy: ShareAddrStrategySchema.default('node'),
|
||||
shareAddr: z.string().default(''),
|
||||
subSortIndex: z.number().int().min(1).default(1),
|
||||
|
||||
@@ -25,6 +25,12 @@ export const NodeRecordSchema = z
|
||||
activeCount: z.number().optional(),
|
||||
disabledCount: z.number().optional(),
|
||||
depletedCount: z.number().optional(),
|
||||
// Per-category client email lists behind the counts above; the nodes
|
||||
// page category chips pop these over on hover.
|
||||
onlineEmails: z.array(z.string()).optional(),
|
||||
activeEmails: z.array(z.string()).optional(),
|
||||
disabledEmails: z.array(z.string()).optional(),
|
||||
depletedEmails: z.array(z.string()).optional(),
|
||||
lastHeartbeat: z.number().optional(),
|
||||
lastError: z.string().optional(),
|
||||
// Xray state captured from the remote node's own /panel/api/server/status.
|
||||
@@ -44,6 +50,17 @@ export const NodeRecordSchema = z
|
||||
guid: z.string().optional(),
|
||||
parentGuid: z.string().optional(),
|
||||
transitive: z.boolean().optional(),
|
||||
// P7: node kind, annotated by the backend for nodes registered by an
|
||||
// agent. Absent/traditional for classic panel-to-panel nodes.
|
||||
nodeType: z.enum(['traditional', 'agent']).optional(),
|
||||
// P13: host IPv4/IPv6 an agent reports with its heartbeat telemetry.
|
||||
// Rendered as plain text in the address column for agent rows.
|
||||
ipv4: z.string().optional(),
|
||||
ipv6: z.string().optional(),
|
||||
// Item 20: last one-click agent update outcome, agent rows only.
|
||||
agentUpdateState: z.string().optional(),
|
||||
agentUpdateError: z.string().optional(),
|
||||
agentUpdateTarget: z.string().optional(),
|
||||
})
|
||||
.loose();
|
||||
|
||||
@@ -66,12 +83,19 @@ export const NodeFormSchema = z
|
||||
id: z.number().optional(),
|
||||
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
||||
remark: z.string().optional(),
|
||||
// Node type: traditional panel-to-panel, or lightweight agent.
|
||||
// Agent nodes skip the panel probe and API token; an agent token is
|
||||
// issued on creation instead.
|
||||
nodeType: z.enum(['traditional', 'agent']).optional().default('traditional'),
|
||||
scheme: z.enum(['http', 'https']),
|
||||
address: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
// For agent nodes the panel never dials the node (agent dials out),
|
||||
// so address/port are not needed. Required for traditional nodes.
|
||||
address: z.string().trim().optional().default(''),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
basePath: z.string(),
|
||||
// mTLS nodes authenticate via the client certificate, so the token is optional
|
||||
// there; every other verify mode still requires one (matches remote.do()).
|
||||
// Agent nodes use an agent token instead of a panel API token.
|
||||
apiToken: z.string().trim(),
|
||||
hasStoredToken: z.boolean().optional().default(false),
|
||||
enable: z.boolean(),
|
||||
@@ -88,6 +112,21 @@ export const NodeFormSchema = z
|
||||
outboundTag: z.string().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.nodeType === 'agent') return; // no panel credentials needed
|
||||
if (!val.address || val.address.trim().length === 0) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['address'],
|
||||
message: 'pages.nodes.toasts.fillRequired',
|
||||
});
|
||||
}
|
||||
if (val.port === undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['port'],
|
||||
message: 'pages.nodes.toasts.fillRequired',
|
||||
});
|
||||
}
|
||||
if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0 && !val.hasStoredToken) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user