Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db6f666ff7 | ||
|
|
d4d259fa58 | ||
|
|
9caa4e0cf9 | ||
|
|
29de9761c8 | ||
|
|
c2f1840535 | ||
|
|
06de0e8384 | ||
|
|
0a6e9b7a7d | ||
|
|
7e15bfbf8e | ||
|
|
6de22ae2e3 | ||
|
|
05c164dc8f | ||
|
|
c610ab88ea | ||
|
|
4a532e8c5a | ||
|
|
3e2829e49d | ||
|
|
19bf6fbc22 | ||
|
|
460b314786 | ||
|
|
3a02211555 | ||
|
|
268e6596bc | ||
|
|
7df7b530d3 | ||
|
|
9e2807d3ec | ||
|
|
e43bd68986 | ||
|
|
b889526b0d | ||
|
|
031b422365 | ||
|
|
277838d7ca | ||
|
|
f646212df2 | ||
|
|
2ee20b9691 | ||
|
|
000731c4e0 | ||
|
|
2558efd9fb | ||
|
|
7ad65652ec | ||
|
|
1762e54d5f | ||
|
|
1763f94a5f | ||
|
|
0d1607c358 | ||
|
|
e03ceaa41f | ||
|
|
741cd905ff | ||
|
|
540c05b0a5 | ||
|
|
d5dab0229c | ||
|
|
0e9c2031fb | ||
|
|
3e052c42b8 | ||
|
|
39ad8b8a2e | ||
|
|
1bd63e6625 | ||
|
|
a47e64f70a | ||
|
|
0a973bc95c | ||
|
|
9843a7ce4e | ||
|
|
750a270955 | ||
|
|
3413ccca9f | ||
|
|
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 |
@@ -19,6 +19,9 @@ backup/
|
|||||||
bin/
|
bin/
|
||||||
x-ui/
|
x-ui/
|
||||||
dist/
|
dist/
|
||||||
|
# Exception: the vendored embedded fallback theme ships its dist/ as
|
||||||
|
# source (go:embed) — it is not a build output.
|
||||||
|
!/internal/web/service/probe_theme_default/dist/
|
||||||
release/
|
release/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
|||||||
+8
-5
@@ -24,13 +24,16 @@
|
|||||||
- 安全设置:双重验证(2FA,用户不用)
|
- 安全设置:双重验证(2FA,用户不用)
|
||||||
- 邮件模块(SMTP 发送:到期/流量提醒等,用户不用)
|
- 邮件模块(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 等)——建议裁
|
- 协议范围:保留全部协议模板(VLESS、SS2022、VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等),不收窄
|
||||||
- IP 检查 / 测速类后台任务——建议裁
|
- IP 检查 / 测速类后台任务:保留 → **2026-10-21 晚用户改口推翻本项**:出站测速 + 出口探测(同一动作,测的是主控本地网络位置;精简主控不跑本机核心、无本地出站,测速结果无意义)连同重启/停止核心控件与接口,已于 P9 第 4 条摘除。其余三项(协议范围 / ACME / 多用户)保留不变,不要再翻出来问。
|
||||||
- 面板内置 ACME 签证书(走 CF Tunnel,不直签)——建议裁
|
- 面板内置 ACME 签证书:保留
|
||||||
- 多用户功能(单管理员使用)——建议裁
|
- 多用户功能:保留
|
||||||
|
- 用户理由:这四块以静态代码为主,裁掉省不了多少内存。此节关闭,后续轮次不要再翻出来问。
|
||||||
|
|
||||||
## 修复(非裁剪,已定)
|
## 修复(非裁剪,已定)
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ cp geoip.dat geosite.dat /usr/local/x-ui/bin/
|
|||||||
```bash
|
```bash
|
||||||
mkdir -p /usr/local/x-ui/bin /etc/x-ui
|
mkdir -p /usr/local/x-ui/bin /etc/x-ui
|
||||||
|
|
||||||
# 1. 放同一个 x-ui 二进制(与主控同版本)
|
# 1. 放同一个二进制(与主控同版本),在节点上改名为 xui-agent(与主控进程区分、避免同机覆盖)
|
||||||
cp x-ui-linux-amd64 /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
|
cp x-ui-linux-amd64 /usr/local/x-ui/xui-agent && chmod +x /usr/local/x-ui/xui-agent
|
||||||
|
|
||||||
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,需手动放置)
|
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,需手动放置)
|
||||||
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
|
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
|
```bash
|
||||||
mkdir -p /usr/local/x-ui/bin /etc/x-ui
|
mkdir -p /usr/local/x-ui/bin /etc/x-ui
|
||||||
|
|
||||||
# 1. 放 x-ui 二进制(agent 补丁版,与主控同版本)
|
# 1. 放 x-ui 二进制(agent 补丁版,与主控同版本),在节点上改名为 xui-agent(与主控进程区分)
|
||||||
cp x-ui /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
|
cp x-ui /usr/local/x-ui/xui-agent && chmod +x /usr/local/x-ui/xui-agent
|
||||||
|
|
||||||
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,手动放置)
|
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,手动放置)
|
||||||
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
|
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ export default function Layout(props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "npm run gen:api && vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "oxlint src tools",
|
"lint": "oxlint src tools",
|
||||||
"lint:fix": "oxlint --fix src tools",
|
"lint:fix": "oxlint --fix src tools",
|
||||||
@@ -22,8 +22,7 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
"gen": "npm run gen:zod && npm run gen:api",
|
"gen": "npm run gen:zod",
|
||||||
"gen:api": "node scripts/build-openapi.mjs",
|
|
||||||
"gen:zod": "cd .. && go run ./tools/openapigen",
|
"gen:zod": "cd .. && go run ./tools/openapigen",
|
||||||
"prepare": "cd .. && husky frontend/.husky || true"
|
"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;
|
|
||||||
@@ -127,6 +127,10 @@ async function performFetch(
|
|||||||
if (data instanceof FormData) {
|
if (data instanceof FormData) {
|
||||||
body = data;
|
body = data;
|
||||||
headers.delete('Content-Type');
|
headers.delete('Content-Type');
|
||||||
|
} else if (data instanceof Blob || data instanceof ArrayBuffer) {
|
||||||
|
// Raw payload (e.g. one chunk of a theme upload): sent as-is.
|
||||||
|
body = data as BodyInit;
|
||||||
|
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/octet-stream');
|
||||||
} else if (!SAFE_METHODS.has(upper)) {
|
} else if (!SAFE_METHODS.has(upper)) {
|
||||||
const declaredType = (headers.get('Content-Type') || '').toLowerCase();
|
const declaredType = (headers.get('Content-Type') || '').toLowerCase();
|
||||||
if (declaredType.startsWith('application/json')) {
|
if (declaredType.startsWith('application/json')) {
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { keys } from '@/api/queryKeys';
|
|
||||||
import {
|
|
||||||
GeoCategoryPageSchema,
|
|
||||||
GeoEntryPageSchema,
|
|
||||||
GeoFileSchema,
|
|
||||||
GeodataTokenIssueSchema,
|
|
||||||
} from '@/generated/zod';
|
|
||||||
import type { GeoCategoryPage, GeoEntryPage, GeoFile, GeodataTokenIssue } from '@/generated/types';
|
|
||||||
import { HttpUtil } from '@/utils';
|
|
||||||
import { parseMsg } from '@/utils/zodValidate';
|
|
||||||
|
|
||||||
const GeoFileListSchema = z.array(GeoFileSchema);
|
|
||||||
const GeodataTokenIssueListSchema = z.array(GeodataTokenIssueSchema);
|
|
||||||
|
|
||||||
const EMPTY_CATEGORY_PAGE: GeoCategoryPage = { total: 0, items: [] };
|
|
||||||
const EMPTY_ENTRY_PAGE: GeoEntryPage = { total: 0, items: [] };
|
|
||||||
|
|
||||||
export type GeoTokenKind = 'ip' | 'domain';
|
|
||||||
|
|
||||||
export interface ValidateGeoTokensInput {
|
|
||||||
tokens: string[];
|
|
||||||
kind: GeoTokenKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchGeodataFiles(): Promise<GeoFile[]> {
|
|
||||||
const msg = await HttpUtil.get('/panel/api/xray/geodata/files', undefined, { silent: true });
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata files');
|
|
||||||
const validated = parseMsg(msg, GeoFileListSchema, 'xray/geodata/files');
|
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchGeodataCategories(file: string, query: string): Promise<GeoCategoryPage> {
|
|
||||||
const msg = await HttpUtil.get(
|
|
||||||
'/panel/api/xray/geodata/categories',
|
|
||||||
{ file, q: query },
|
|
||||||
{ silent: true },
|
|
||||||
);
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories');
|
|
||||||
const validated = parseMsg(msg, GeoCategoryPageSchema, 'xray/geodata/categories');
|
|
||||||
return validated.obj ?? EMPTY_CATEGORY_PAGE;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchGeodataEntries(
|
|
||||||
file: string,
|
|
||||||
code: string,
|
|
||||||
query: string,
|
|
||||||
offset: number,
|
|
||||||
limit: number,
|
|
||||||
): Promise<GeoEntryPage> {
|
|
||||||
const msg = await HttpUtil.get(
|
|
||||||
'/panel/api/xray/geodata/entries',
|
|
||||||
{ file, code, q: query, offset, limit },
|
|
||||||
{ silent: true },
|
|
||||||
);
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata entries');
|
|
||||||
const validated = parseMsg(msg, GeoEntryPageSchema, 'xray/geodata/entries');
|
|
||||||
return validated.obj ?? EMPTY_ENTRY_PAGE;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGeodataFiles(enabled: boolean) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: keys.xray.geodata.files(),
|
|
||||||
queryFn: fetchGeodataFiles,
|
|
||||||
enabled,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGeodataCategories(file: string | undefined, query: string, enabled: boolean) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: keys.xray.geodata.categories(file ?? '', query),
|
|
||||||
queryFn: () => fetchGeodataCategories(file ?? '', query),
|
|
||||||
enabled: enabled && !!file,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGeodataEntries(
|
|
||||||
file: string | undefined,
|
|
||||||
code: string | undefined,
|
|
||||||
query: string,
|
|
||||||
offset: number,
|
|
||||||
limit: number,
|
|
||||||
enabled: boolean,
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: keys.xray.geodata.entries(file ?? '', code ?? '', query, offset, limit),
|
|
||||||
queryFn: () => fetchGeodataEntries(file ?? '', code ?? '', query, offset, limit),
|
|
||||||
enabled: enabled && !!file && !!code,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useValidateGeoTokens() {
|
|
||||||
return useMutation<GeodataTokenIssue[], Error, ValidateGeoTokensInput>({
|
|
||||||
mutationFn: async ({ tokens, kind }) => {
|
|
||||||
const msg = await HttpUtil.post(
|
|
||||||
'/panel/api/xray/geodata/validate',
|
|
||||||
{ tokens: tokens.join(','), kind },
|
|
||||||
{ silent: true },
|
|
||||||
);
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to validate geodata tokens');
|
|
||||||
const validated = parseMsg(msg, GeodataTokenIssueListSchema, 'xray/geodata/validate');
|
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,58 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
import { fetchXrayConfig } from '@/hooks/useXraySetting';
|
|
||||||
|
// The outbound tag directory: the factory template's outbounds and
|
||||||
|
// routing balancers plus the tags contributed by outbound
|
||||||
|
// subscriptions. It used to be derived from the xray settings page's
|
||||||
|
// config payload; since the template became code-fixed (ITEM6 B4)
|
||||||
|
// the panel serves the same directory from a dedicated read endpoint.
|
||||||
|
export interface OutboundDirectory {
|
||||||
|
outbounds: { tag: string; protocol: string }[];
|
||||||
|
balancers: string[];
|
||||||
|
subscriptionOutboundTags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOutboundDirectory(): Promise<OutboundDirectory> {
|
||||||
|
const msg = await HttpUtil.get('/panel/api/server/outboundDirectory', undefined, {
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbound directory');
|
||||||
|
const obj = (msg.obj ?? {}) as Partial<OutboundDirectory>;
|
||||||
|
return {
|
||||||
|
outbounds: Array.isArray(obj.outbounds) ? obj.outbounds : [],
|
||||||
|
balancers: Array.isArray(obj.balancers) ? obj.balancers : [],
|
||||||
|
subscriptionOutboundTags: Array.isArray(obj.subscriptionOutboundTags)
|
||||||
|
? obj.subscriptionOutboundTags
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Available outbound (and balancer-eligible) tags the user can route an mtproto
|
// Available outbound (and balancer-eligible) tags the user can route an mtproto
|
||||||
// inbound's Telegram traffic to. Shares the cached xray config query so opening
|
// inbound's Telegram traffic to. Shares one cached directory query so opening
|
||||||
// the inbound form costs no extra request when the Xray page was already
|
// the inbound form costs no extra request when another picker already
|
||||||
// visited; `select` derives just the tag list without disturbing other readers.
|
// visited; `select` derives just the tag list without disturbing other readers.
|
||||||
export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
|
export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
|
||||||
const excludeBlackhole = opts?.excludeBlackhole ?? false;
|
const excludeBlackhole = opts?.excludeBlackhole ?? false;
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: keys.xray.config(),
|
queryKey: keys.server.outboundDirectory(),
|
||||||
queryFn: fetchXrayConfig,
|
queryFn: fetchOutboundDirectory,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
select: (data): string[] => {
|
select: (data): string[] => {
|
||||||
const tags = new Set<string>();
|
const tags = new Set<string>();
|
||||||
for (const o of data?.xraySetting?.outbounds ?? []) {
|
for (const ob of data.outbounds) {
|
||||||
const ob = o as { tag?: string; protocol?: string } | null;
|
|
||||||
if (!ob?.tag) continue;
|
if (!ob?.tag) continue;
|
||||||
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
|
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
|
||||||
tags.add(ob.tag);
|
tags.add(ob.tag);
|
||||||
}
|
}
|
||||||
for (const t of data?.subscriptionOutboundTags ?? []) {
|
for (const t of data.subscriptionOutboundTags) {
|
||||||
if (t) tags.add(t);
|
if (t) tags.add(t);
|
||||||
}
|
}
|
||||||
// Balancers are valid routing targets too — injectMtprotoEgress emits a
|
// Balancers are valid routing targets too — injectMtprotoEgress emits a
|
||||||
// balancerTag rule when the chosen tag names a balancer.
|
// balancerTag rule when the chosen tag names a balancer.
|
||||||
const balancers = (
|
for (const tag of data.balancers) {
|
||||||
data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
|
if (tag) tags.add(tag);
|
||||||
)?.balancers;
|
|
||||||
for (const b of balancers ?? []) {
|
|
||||||
if (b?.tag) tags.add(b.tag);
|
|
||||||
}
|
}
|
||||||
return [...tags];
|
return [...tags];
|
||||||
},
|
},
|
||||||
@@ -48,26 +70,22 @@ export interface OutboundTagGroups {
|
|||||||
export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
|
export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
|
||||||
const excludeBlackhole = opts?.excludeBlackhole ?? false;
|
const excludeBlackhole = opts?.excludeBlackhole ?? false;
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: keys.xray.config(),
|
queryKey: keys.server.outboundDirectory(),
|
||||||
queryFn: fetchXrayConfig,
|
queryFn: fetchOutboundDirectory,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
select: (data): OutboundTagGroups => {
|
select: (data): OutboundTagGroups => {
|
||||||
const outbounds = new Set<string>();
|
const outbounds = new Set<string>();
|
||||||
for (const o of data?.xraySetting?.outbounds ?? []) {
|
for (const ob of data.outbounds) {
|
||||||
const ob = o as { tag?: string; protocol?: string } | null;
|
|
||||||
if (!ob?.tag) continue;
|
if (!ob?.tag) continue;
|
||||||
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
|
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
|
||||||
outbounds.add(ob.tag);
|
outbounds.add(ob.tag);
|
||||||
}
|
}
|
||||||
for (const t of data?.subscriptionOutboundTags ?? []) {
|
for (const t of data.subscriptionOutboundTags) {
|
||||||
if (t) outbounds.add(t);
|
if (t) outbounds.add(t);
|
||||||
}
|
}
|
||||||
const balancers: string[] = [];
|
const balancers: string[] = [];
|
||||||
const bal = (
|
for (const tag of data.balancers) {
|
||||||
data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
|
if (tag && !outbounds.has(tag)) balancers.push(tag);
|
||||||
)?.balancers;
|
|
||||||
for (const b of bal ?? []) {
|
|
||||||
if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag);
|
|
||||||
}
|
}
|
||||||
return { outbounds: [...outbounds], balancers };
|
return { outbounds: [...outbounds], balancers };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
|
||||||
import { keys } from '@/api/queryKeys';
|
|
||||||
import type { SubBalancerFormValues } from '@/schemas/subBalancer';
|
|
||||||
|
|
||||||
// Deliberately urlencoded (no JSON headers): the Go side binds inboundIds from
|
|
||||||
// repeated form keys, which is exactly how HttpUtil encodes arrays.
|
|
||||||
export function useSubBalancerMutations() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: keys.subBalancers.root() });
|
|
||||||
|
|
||||||
const createMut = useMutation({
|
|
||||||
mutationFn: (payload: SubBalancerFormValues) =>
|
|
||||||
HttpUtil.post('/panel/api/sub-balancers', payload),
|
|
||||||
onSuccess: (msg) => {
|
|
||||||
if (msg?.success) invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMut = useMutation({
|
|
||||||
mutationFn: ({ id, payload }: { id: number; payload: SubBalancerFormValues }) =>
|
|
||||||
HttpUtil.post(`/panel/api/sub-balancers/${id}`, payload),
|
|
||||||
onSuccess: (msg) => {
|
|
||||||
if (msg?.success) invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const removeMut = useMutation({
|
|
||||||
mutationFn: (id: number) => HttpUtil.post(`/panel/api/sub-balancers/${id}/del`),
|
|
||||||
onSuccess: (msg) => {
|
|
||||||
if (msg?.success) invalidate();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
create: (payload: SubBalancerFormValues) => createMut.mutateAsync(payload),
|
|
||||||
update: (id: number, payload: SubBalancerFormValues) => updateMut.mutateAsync({ id, payload }),
|
|
||||||
remove: (id: number) => removeMut.mutateAsync(id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { HttpUtil } from '@/utils';
|
|
||||||
import { parseMsg } from '@/utils/zodValidate';
|
|
||||||
import { keys } from '@/api/queryKeys';
|
|
||||||
import { SubBalancerListSchema, type SubBalancer } from '@/schemas/subBalancer';
|
|
||||||
|
|
||||||
async function fetchSubBalancers(): Promise<SubBalancer[]> {
|
|
||||||
const msg = await HttpUtil.get('/panel/api/sub-balancers', undefined, { silent: true });
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch subscription balancers');
|
|
||||||
const validated = parseMsg(msg, SubBalancerListSchema, 'sub-balancers');
|
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubBalancersQuery() {
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: keys.subBalancers.list(),
|
|
||||||
queryFn: fetchSubBalancers,
|
|
||||||
});
|
|
||||||
|
|
||||||
const balancers = useMemo(() => query.data ?? [], [query.data]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
balancers,
|
|
||||||
loading: query.isFetching,
|
|
||||||
fetched: query.data !== undefined || query.isError,
|
|
||||||
fetchError: query.error ? (query.error as Error).message : '',
|
|
||||||
refetch: query.refetch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ export const keys = {
|
|||||||
server: {
|
server: {
|
||||||
status: () => ['server', 'status'] as const,
|
status: () => ['server', 'status'] as const,
|
||||||
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
|
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
|
||||||
|
outboundDirectory: () => ['server', 'outboundDirectory'] as const,
|
||||||
},
|
},
|
||||||
nodes: {
|
nodes: {
|
||||||
root: () => ['nodes'] as const,
|
root: () => ['nodes'] as const,
|
||||||
@@ -13,10 +14,6 @@ export const keys = {
|
|||||||
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
|
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
|
||||||
tags: () => ['hosts', 'tags'] as const,
|
tags: () => ['hosts', 'tags'] as const,
|
||||||
},
|
},
|
||||||
subBalancers: {
|
|
||||||
root: () => ['sub-balancers'] as const,
|
|
||||||
list: () => ['sub-balancers', 'list'] as const,
|
|
||||||
},
|
|
||||||
settings: {
|
settings: {
|
||||||
root: () => ['settings'] as const,
|
root: () => ['settings'] as const,
|
||||||
all: () => ['settings', 'all'] as const,
|
all: () => ['settings', 'all'] as const,
|
||||||
@@ -36,19 +33,5 @@ export const keys = {
|
|||||||
onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
|
onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
|
||||||
activeInbounds: () => ['clients', 'activeInbounds'] as const,
|
activeInbounds: () => ['clients', 'activeInbounds'] as const,
|
||||||
lastOnline: () => ['clients', 'lastOnline'] as const,
|
lastOnline: () => ['clients', 'lastOnline'] as const,
|
||||||
groups: () => ['clients', 'groups'] as const,
|
|
||||||
},
|
|
||||||
xray: {
|
|
||||||
root: () => ['xray'] as const,
|
|
||||||
config: () => ['xray', 'config'] as const,
|
|
||||||
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
|
|
||||||
geodata: {
|
|
||||||
root: () => ['xray', 'geodata'] as const,
|
|
||||||
files: () => ['xray', 'geodata', 'files'] as const,
|
|
||||||
categories: (file: string, query: string) =>
|
|
||||||
['xray', 'geodata', 'categories', file, query] as const,
|
|
||||||
entries: (file: string, code: string, query: string, offset: number, limit: number) =>
|
|
||||||
['xray', 'geodata', 'entries', file, code, query, offset, limit] as const,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ export function useWebSocketBridge() {
|
|||||||
}, 200);
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOutbounds: Handler = (payload) => {
|
|
||||||
if (!Array.isArray(payload)) return;
|
|
||||||
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onNodes: Handler = (payload) => {
|
const onNodes: Handler = (payload) => {
|
||||||
if (!Array.isArray(payload)) return;
|
if (!Array.isArray(payload)) return;
|
||||||
queryClient.setQueryData(keys.nodes.list(), payload);
|
queryClient.setQueryData(keys.nodes.list(), payload);
|
||||||
@@ -46,14 +41,12 @@ export function useWebSocketBridge() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
client.on('invalidate', onInvalidate);
|
client.on('invalidate', onInvalidate);
|
||||||
client.on('outbounds', onOutbounds);
|
|
||||||
client.on('nodes', onNodes);
|
client.on('nodes', onNodes);
|
||||||
client.on('inbounds', onInbounds);
|
client.on('inbounds', onInbounds);
|
||||||
client.connect();
|
client.connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
client.off('invalidate', onInvalidate);
|
client.off('invalidate', onInvalidate);
|
||||||
client.off('outbounds', onOutbounds);
|
|
||||||
client.off('nodes', onNodes);
|
client.off('nodes', onNodes);
|
||||||
client.off('inbounds', onInbounds);
|
client.off('inbounds', onInbounds);
|
||||||
if (invalidateTimer != null) {
|
if (invalidateTimer != null) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { Typography } from 'antd';
|
import { Typography } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -25,15 +25,6 @@ function ClientExpiryDemo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function JalaliDemo() {
|
|
||||||
const [value, setValue] = useState<Dayjs | null>(dayjs('2026-12-31 23:59:59'));
|
|
||||||
useEffect(() => {
|
|
||||||
setDatepicker('jalalian');
|
|
||||||
return () => setDatepicker('gregorian');
|
|
||||||
}, []);
|
|
||||||
return <DateTimePicker value={value} onChange={setValue} placeholder="Expiry date" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Form/DateTimePicker',
|
title: 'Form/DateTimePicker',
|
||||||
component: DateTimePicker,
|
component: DateTimePicker,
|
||||||
@@ -43,7 +34,7 @@ const meta = {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Calendar-aware date/time picker used for client and inbound expiry dates. Renders an AntD DatePicker by default and switches to a Persian (Jalali) calendar — with theme-matched colors and an overlaid clear button — when the panel datepicker setting is jalalian.',
|
'Date/time picker used for client and inbound expiry dates, rendered with the AntD DatePicker.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -90,9 +81,3 @@ export const DateOnly: Story = {
|
|||||||
placeholder: 'Start date',
|
placeholder: 'Start date',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const JalaliCalendar: Story = {
|
|
||||||
args: { value: null, onChange: () => undefined },
|
|
||||||
parameters: { docs: { disable: true } },
|
|
||||||
render: () => <JalaliDemo />,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { CloseCircleFilled } from '@ant-design/icons';
|
|
||||||
import { DatePicker } from 'antd';
|
import { DatePicker } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { PersianDateTimePicker } from 'persian-calendar-suite';
|
|
||||||
|
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
|
||||||
import './DateTimePicker.css';
|
|
||||||
|
|
||||||
interface DateTimePickerProps {
|
interface DateTimePickerProps {
|
||||||
value: Dayjs | null;
|
value: Dayjs | null;
|
||||||
@@ -19,33 +10,6 @@ interface DateTimePickerProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIGHT_THEME = {
|
|
||||||
primaryColor: '#1677ff',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderColor: '#d9d9d9',
|
|
||||||
hoverColor: 'rgba(22, 119, 255, 0.10)',
|
|
||||||
selectedTextColor: '#ffffff',
|
|
||||||
textColor: 'rgba(0, 0, 0, 0.88)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DARK_THEME = {
|
|
||||||
primaryColor: '#1677ff',
|
|
||||||
backgroundColor: '#23252b',
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
|
||||||
hoverColor: 'rgba(22, 119, 255, 0.18)',
|
|
||||||
selectedTextColor: '#ffffff',
|
|
||||||
textColor: 'rgba(255, 255, 255, 0.88)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ULTRA_DARK_THEME = {
|
|
||||||
primaryColor: '#1677ff',
|
|
||||||
backgroundColor: '#101013',
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.08)',
|
|
||||||
hoverColor: 'rgba(22, 119, 255, 0.16)',
|
|
||||||
selectedTextColor: '#ffffff',
|
|
||||||
textColor: 'rgba(255, 255, 255, 0.88)',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DateTimePicker({
|
export default function DateTimePicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -54,83 +18,6 @@ export default function DateTimePicker({
|
|||||||
placeholder = '',
|
placeholder = '',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DateTimePickerProps) {
|
}: DateTimePickerProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { datepicker } = useDatepicker();
|
|
||||||
const { isDark, isUltra } = useTheme();
|
|
||||||
const jalaliRef = useRef<HTMLDivElement>(null);
|
|
||||||
// Bumped on clear: persian-calendar-suite reads `value` only on mount, so
|
|
||||||
// remounting via key is the only way to reflect an externally cleared value.
|
|
||||||
const [clearNonce, setClearNonce] = useState(0);
|
|
||||||
// Mounted without a value, persian-calendar-suite seeds today and emits it —
|
|
||||||
// which would instantly undo a clear. Armed across every (re)mount.
|
|
||||||
const suppressMountEmit = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
suppressMountEmit.current = false;
|
|
||||||
return () => {
|
|
||||||
suppressMountEmit.current = true;
|
|
||||||
};
|
|
||||||
}, [clearNonce]);
|
|
||||||
|
|
||||||
const persianTheme = useMemo(() => {
|
|
||||||
if (isUltra) return ULTRA_DARK_THEME;
|
|
||||||
if (isDark) return DARK_THEME;
|
|
||||||
return LIGHT_THEME;
|
|
||||||
}, [isDark, isUltra]);
|
|
||||||
|
|
||||||
// The library hardcodes a Persian placeholder and exposes no working prop to
|
|
||||||
// override it, so clear it (or apply the caller's) on the input directly so
|
|
||||||
// the empty field shows no leftover Persian text. No dep array: re-apply
|
|
||||||
// after every render (incl. clear-remounts).
|
|
||||||
useEffect(() => {
|
|
||||||
if (datepicker !== 'jalalian') return;
|
|
||||||
const input = jalaliRef.current?.querySelector('input');
|
|
||||||
if (input) input.placeholder = placeholder;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (datepicker === 'jalalian') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={jalaliRef}
|
|
||||||
className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}${value ? '' : ' jdp-empty'}`}
|
|
||||||
>
|
|
||||||
<PersianDateTimePicker
|
|
||||||
key={clearNonce}
|
|
||||||
value={value ? value.valueOf() : null}
|
|
||||||
onChange={(next: number | string | null) => {
|
|
||||||
if (suppressMountEmit.current) return;
|
|
||||||
if (next == null || next === '') {
|
|
||||||
onChange(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ms = typeof next === 'number' ? next : Number(next);
|
|
||||||
if (Number.isFinite(ms)) onChange(dayjs(ms));
|
|
||||||
}}
|
|
||||||
showTime={showTime}
|
|
||||||
outputFormat="timestamp"
|
|
||||||
persianNumbers
|
|
||||||
rtlCalendar
|
|
||||||
theme={persianTheme}
|
|
||||||
/>
|
|
||||||
{value && !disabled && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="jdp-clear"
|
|
||||||
aria-label={t('clear')}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onChange(null);
|
|
||||||
setClearNonce((n) => n + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloseCircleFilled />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
.geo-browser-modal .geo-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-toolbar .ant-input-search {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-meta {
|
|
||||||
margin-inline-start: auto;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-columns {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(240px, 340px) minmax(0, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
height: 440px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Both panes are the same fixed height, and the pager sits on the pane's floor
|
|
||||||
rather than under the last row, so neither the dialog nor its controls move
|
|
||||||
as the user steps between categories with wildly different rule counts. */
|
|
||||||
.geo-browser-modal .geo-panel {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--ant-color-border-secondary);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-panel .ant-table-wrapper,
|
|
||||||
.geo-browser-modal .geo-panel .ant-spin-nested-loading,
|
|
||||||
.geo-browser-modal .geo-panel .ant-spin-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-panel .ant-table {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The rules table fills whatever is left between the header and the pager
|
|
||||||
instead of carrying a hardcoded scroll height, so there is no dead strip
|
|
||||||
above the pager and short categories do not scroll needlessly. */
|
|
||||||
.geo-browser-modal .geo-preview-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-pager {
|
|
||||||
margin-top: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-top: 1px solid var(--ant-color-border-secondary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-pager .ant-pagination-total-text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-categories .ant-table-row {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-row-active > td {
|
|
||||||
background: var(--ant-color-primary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-category {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-code,
|
|
||||||
.geo-browser-modal .geo-entry-value,
|
|
||||||
.geo-browser-modal .geo-preview-title {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-attrs .ant-tag {
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 16px;
|
|
||||||
margin-inline-end: 4px;
|
|
||||||
padding-inline: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-count {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-preview-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The title is the part that gives way: without min-width it refuses to
|
|
||||||
shrink, and the filter is pushed onto a second line instead of the long
|
|
||||||
category name being clipped. */
|
|
||||||
.geo-browser-modal .geo-preview-title {
|
|
||||||
flex: 0 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-preview-head .ant-typography {
|
|
||||||
flex: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-entry-filter {
|
|
||||||
flex: none;
|
|
||||||
width: 200px;
|
|
||||||
margin-inline-start: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
|
||||||
.geo-browser-modal .geo-preview-head {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-entry-filter {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-kind {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-kind-full {
|
|
||||||
color: var(--ant-color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-kind-keyword {
|
|
||||||
color: var(--ant-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-kind-regexp {
|
|
||||||
color: var(--ant-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-placeholder {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid var(--ant-color-border-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-chips {
|
|
||||||
flex: 1;
|
|
||||||
max-height: 76px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-selected-count {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ant-color-text-tertiary);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.geo-browser-modal .geo-columns {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-browser-modal .geo-panel {
|
|
||||||
height: 320px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.geo-unknown-hint {
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
@@ -1,864 +0,0 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
|
||||||
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { expect, within } from 'storybook/test';
|
|
||||||
import { Button, Space, Typography } from 'antd';
|
|
||||||
|
|
||||||
import type { GeoCategory, GeoEntry, GeoFile } from '@/generated/types';
|
|
||||||
|
|
||||||
import GeoBrowserModal, { type GeoBrowserModalProps } from './GeoBrowserModal';
|
|
||||||
|
|
||||||
type GeoResponder = (query: URLSearchParams) => unknown;
|
|
||||||
type GeoRoutes = Record<string, GeoResponder>;
|
|
||||||
|
|
||||||
const realFetch = window.fetch.bind(window);
|
|
||||||
let activeRoutes: GeoRoutes = {};
|
|
||||||
|
|
||||||
function requestUrl(input: RequestInfo | URL): URL {
|
|
||||||
if (typeof input === 'string') return new URL(input, window.location.origin);
|
|
||||||
if (input instanceof URL) return input;
|
|
||||||
return new URL(input.url, window.location.origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
||||||
const url = requestUrl(input);
|
|
||||||
const responder = activeRoutes[url.pathname];
|
|
||||||
if (!responder) return realFetch(input, init);
|
|
||||||
const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams) });
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function activate(routes: GeoRoutes): void {
|
|
||||||
activeRoutes = routes;
|
|
||||||
window.fetch = geoFetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deactivate(routes: GeoRoutes): void {
|
|
||||||
if (activeRoutes === routes) activeRoutes = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
|
|
||||||
const [client] = useState(() => {
|
|
||||||
activate(routes);
|
|
||||||
return new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
activate(routes);
|
|
||||||
return () => deactivate(routes);
|
|
||||||
}, [routes]);
|
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
|
|
||||||
const full = (value: string): GeoEntry => ({ kind: 'full', value });
|
|
||||||
const keyword = (value: string): GeoEntry => ({ kind: 'keyword', value });
|
|
||||||
const regexp = (value: string): GeoEntry => ({ kind: 'regexp', value });
|
|
||||||
const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
|
|
||||||
|
|
||||||
const cross = (names: string[], suffixes: string[]): GeoEntry[] =>
|
|
||||||
names.flatMap((name) => suffixes.map((suffix) => domain(`${name}.${suffix}`)));
|
|
||||||
|
|
||||||
const CC_TLDS = [
|
|
||||||
'ae',
|
|
||||||
'al',
|
|
||||||
'am',
|
|
||||||
'at',
|
|
||||||
'az',
|
|
||||||
'ba',
|
|
||||||
'be',
|
|
||||||
'bg',
|
|
||||||
'bi',
|
|
||||||
'bj',
|
|
||||||
'ca',
|
|
||||||
'cat',
|
|
||||||
'cd',
|
|
||||||
'cf',
|
|
||||||
'cg',
|
|
||||||
'ch',
|
|
||||||
'ci',
|
|
||||||
'cl',
|
|
||||||
'cm',
|
|
||||||
'co.id',
|
|
||||||
'co.il',
|
|
||||||
'co.in',
|
|
||||||
'co.jp',
|
|
||||||
'co.ke',
|
|
||||||
'co.kr',
|
|
||||||
'co.ma',
|
|
||||||
'co.nz',
|
|
||||||
'co.th',
|
|
||||||
'co.uk',
|
|
||||||
'co.uz',
|
|
||||||
'co.ve',
|
|
||||||
'co.za',
|
|
||||||
'com.ar',
|
|
||||||
'com.au',
|
|
||||||
'com.bd',
|
|
||||||
'com.br',
|
|
||||||
'com.co',
|
|
||||||
'com.cu',
|
|
||||||
'com.eg',
|
|
||||||
'com.gt',
|
|
||||||
'com.hk',
|
|
||||||
'com.mx',
|
|
||||||
'com.my',
|
|
||||||
'com.ng',
|
|
||||||
'com.pe',
|
|
||||||
'com.ph',
|
|
||||||
'com.pk',
|
|
||||||
'com.sa',
|
|
||||||
'com.sg',
|
|
||||||
'com.tr',
|
|
||||||
'com.tw',
|
|
||||||
'com.ua',
|
|
||||||
'com.uy',
|
|
||||||
'com.vn',
|
|
||||||
'cz',
|
|
||||||
'de',
|
|
||||||
'dj',
|
|
||||||
'dk',
|
|
||||||
'dz',
|
|
||||||
'ee',
|
|
||||||
'es',
|
|
||||||
'fi',
|
|
||||||
'fr',
|
|
||||||
'ga',
|
|
||||||
'ge',
|
|
||||||
'gl',
|
|
||||||
'gm',
|
|
||||||
'gr',
|
|
||||||
'hn',
|
|
||||||
'hr',
|
|
||||||
'ht',
|
|
||||||
'hu',
|
|
||||||
'ie',
|
|
||||||
'iq',
|
|
||||||
'is',
|
|
||||||
'it',
|
|
||||||
'je',
|
|
||||||
'jo',
|
|
||||||
'kg',
|
|
||||||
'kz',
|
|
||||||
'la',
|
|
||||||
'li',
|
|
||||||
'lk',
|
|
||||||
'lt',
|
|
||||||
'lu',
|
|
||||||
'lv',
|
|
||||||
'ly',
|
|
||||||
'md',
|
|
||||||
'me',
|
|
||||||
'mg',
|
|
||||||
'mk',
|
|
||||||
'ml',
|
|
||||||
'mn',
|
|
||||||
'mu',
|
|
||||||
'mv',
|
|
||||||
'mw',
|
|
||||||
'ne',
|
|
||||||
'nl',
|
|
||||||
'no',
|
|
||||||
'nu',
|
|
||||||
'pl',
|
|
||||||
'pt',
|
|
||||||
'ro',
|
|
||||||
'rs',
|
|
||||||
'ru',
|
|
||||||
'rw',
|
|
||||||
'se',
|
|
||||||
'sh',
|
|
||||||
'si',
|
|
||||||
'sk',
|
|
||||||
'sm',
|
|
||||||
'sn',
|
|
||||||
'so',
|
|
||||||
'sr',
|
|
||||||
'st',
|
|
||||||
'td',
|
|
||||||
'tg',
|
|
||||||
'tk',
|
|
||||||
'tl',
|
|
||||||
'tm',
|
|
||||||
'tn',
|
|
||||||
'to',
|
|
||||||
'tt',
|
|
||||||
'vg',
|
|
||||||
'vu',
|
|
||||||
'ws',
|
|
||||||
];
|
|
||||||
|
|
||||||
const AD_HOSTS = [
|
|
||||||
'adform',
|
|
||||||
'adnxs',
|
|
||||||
'adroll',
|
|
||||||
'adsrvr',
|
|
||||||
'amplitude',
|
|
||||||
'appsflyer',
|
|
||||||
'bluekai',
|
|
||||||
'branch',
|
|
||||||
'casalemedia',
|
|
||||||
'criteo',
|
|
||||||
'flurry',
|
|
||||||
'moatads',
|
|
||||||
'mopub',
|
|
||||||
'openx',
|
|
||||||
'outbrain',
|
|
||||||
'pubmatic',
|
|
||||||
'quantserve',
|
|
||||||
'rubiconproject',
|
|
||||||
'scorecardresearch',
|
|
||||||
'sharethrough',
|
|
||||||
'smartadserver',
|
|
||||||
'taboola',
|
|
||||||
'teads',
|
|
||||||
'yieldmo',
|
|
||||||
'zemanta',
|
|
||||||
];
|
|
||||||
|
|
||||||
const CN_BRANDS = [
|
|
||||||
'58',
|
|
||||||
'alibaba',
|
|
||||||
'alipay',
|
|
||||||
'aliyun',
|
|
||||||
'baidu',
|
|
||||||
'bilibili',
|
|
||||||
'cnblogs',
|
|
||||||
'csdn',
|
|
||||||
'ctrip',
|
|
||||||
'douban',
|
|
||||||
'gitee',
|
|
||||||
'huawei',
|
|
||||||
'iqiyi',
|
|
||||||
'jd',
|
|
||||||
'kuaishou',
|
|
||||||
'meituan',
|
|
||||||
'netease',
|
|
||||||
'pinduoduo',
|
|
||||||
'qq',
|
|
||||||
'sina',
|
|
||||||
'sohu',
|
|
||||||
'taobao',
|
|
||||||
'tencent',
|
|
||||||
'tmall',
|
|
||||||
'toutiao',
|
|
||||||
'weibo',
|
|
||||||
'xiaomi',
|
|
||||||
'youku',
|
|
||||||
'zhihu',
|
|
||||||
];
|
|
||||||
|
|
||||||
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
|
|
||||||
amazon: [
|
|
||||||
domain('amazon.com'),
|
|
||||||
domain('amazonaws.com'),
|
|
||||||
domain('media-amazon.com'),
|
|
||||||
domain('ssl-images-amazon.com'),
|
|
||||||
domain('primevideo.com'),
|
|
||||||
domain('awsstatic.com'),
|
|
||||||
domain('cloudfront.net'),
|
|
||||||
full('www.amazon.co.jp'),
|
|
||||||
],
|
|
||||||
apple: [
|
|
||||||
domain('apple.com'),
|
|
||||||
domain('icloud.com'),
|
|
||||||
domain('cdn-apple.com'),
|
|
||||||
domain('mzstatic.com'),
|
|
||||||
domain('apple-cloudkit.com'),
|
|
||||||
domain('itunes.com'),
|
|
||||||
domain('me.com'),
|
|
||||||
domain('appstore.com'),
|
|
||||||
],
|
|
||||||
'category-ads': [
|
|
||||||
domain('adcolony.com'),
|
|
||||||
domain('applovin.com'),
|
|
||||||
domain('chartboost.com'),
|
|
||||||
domain('inmobi.com'),
|
|
||||||
domain('unityads.unity3d.com'),
|
|
||||||
keyword('banner-ad'),
|
|
||||||
],
|
|
||||||
'category-ads-all': [
|
|
||||||
domain('doubleclick.net'),
|
|
||||||
domain('googleadservices.com'),
|
|
||||||
domain('googlesyndication.com'),
|
|
||||||
domain('adservice.google.com'),
|
|
||||||
full('ads.yahoo.com'),
|
|
||||||
keyword('adservice'),
|
|
||||||
keyword('advertising'),
|
|
||||||
regexp('^ad[0-9]{1,3}\\.'),
|
|
||||||
...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']),
|
|
||||||
],
|
|
||||||
cloudflare: [
|
|
||||||
domain('cloudflare.com'),
|
|
||||||
domain('cloudflare-dns.com'),
|
|
||||||
domain('cloudflareinsights.com'),
|
|
||||||
domain('workers.dev'),
|
|
||||||
domain('pages.dev'),
|
|
||||||
domain('cf-ipfs.com'),
|
|
||||||
],
|
|
||||||
cn: [full('www.gov.cn'), keyword('chinanet'), ...cross(CN_BRANDS, ['com', 'cn', 'com.cn'])],
|
|
||||||
discord: [
|
|
||||||
domain('discord.com'),
|
|
||||||
domain('discord.gg'),
|
|
||||||
domain('discordapp.com'),
|
|
||||||
domain('discordapp.net'),
|
|
||||||
domain('discord.media'),
|
|
||||||
],
|
|
||||||
facebook: [
|
|
||||||
domain('facebook.com'),
|
|
||||||
domain('fbcdn.net'),
|
|
||||||
domain('fb.com'),
|
|
||||||
domain('messenger.com'),
|
|
||||||
domain('fbsbx.com'),
|
|
||||||
domain('facebook.net'),
|
|
||||||
full('m.facebook.com'),
|
|
||||||
],
|
|
||||||
'geolocation-!cn': [
|
|
||||||
keyword('proxy'),
|
|
||||||
regexp('.*\\.onion$'),
|
|
||||||
domain('wikipedia.org'),
|
|
||||||
domain('bbc.com'),
|
|
||||||
domain('nytimes.com'),
|
|
||||||
domain('reuters.com'),
|
|
||||||
domain('medium.com'),
|
|
||||||
domain('reddit.com'),
|
|
||||||
],
|
|
||||||
'geolocation-cn': [
|
|
||||||
domain('gov.cn'),
|
|
||||||
domain('edu.cn'),
|
|
||||||
domain('org.cn'),
|
|
||||||
domain('net.cn'),
|
|
||||||
...cross(CN_BRANDS.slice(0, 18), ['cn']),
|
|
||||||
],
|
|
||||||
github: [
|
|
||||||
domain('github.com'),
|
|
||||||
domain('githubusercontent.com'),
|
|
||||||
domain('githubassets.com'),
|
|
||||||
domain('github.io'),
|
|
||||||
domain('ghcr.io'),
|
|
||||||
domain('git.io'),
|
|
||||||
],
|
|
||||||
google: [
|
|
||||||
domain('google.com'),
|
|
||||||
domain('googleapis.com'),
|
|
||||||
domain('gstatic.com'),
|
|
||||||
domain('googleusercontent.com'),
|
|
||||||
domain('google-analytics.com'),
|
|
||||||
domain('googletagmanager.com'),
|
|
||||||
domain('ggpht.com'),
|
|
||||||
domain('withgoogle.com'),
|
|
||||||
domain('android.com'),
|
|
||||||
domain('chromium.org'),
|
|
||||||
domain('abc.xyz'),
|
|
||||||
full('dl.google.com'),
|
|
||||||
...CC_TLDS.map((tld) => domain(`google.${tld}`)),
|
|
||||||
],
|
|
||||||
instagram: [domain('instagram.com'), domain('cdninstagram.com'), domain('ig.me')],
|
|
||||||
microsoft: [
|
|
||||||
domain('microsoft.com'),
|
|
||||||
domain('live.com'),
|
|
||||||
domain('office.com'),
|
|
||||||
domain('office365.com'),
|
|
||||||
domain('windows.net'),
|
|
||||||
domain('windowsupdate.com'),
|
|
||||||
domain('msn.com'),
|
|
||||||
domain('azure.com'),
|
|
||||||
domain('sharepoint.com'),
|
|
||||||
domain('skype.com'),
|
|
||||||
domain('bing.com'),
|
|
||||||
],
|
|
||||||
netflix: [
|
|
||||||
domain('netflix.com'),
|
|
||||||
domain('netflix.net'),
|
|
||||||
domain('nflximg.com'),
|
|
||||||
domain('nflximg.net'),
|
|
||||||
domain('nflxvideo.net'),
|
|
||||||
domain('nflxso.net'),
|
|
||||||
domain('nflxext.com'),
|
|
||||||
full('fast.com'),
|
|
||||||
],
|
|
||||||
openai: [
|
|
||||||
domain('openai.com'),
|
|
||||||
domain('chatgpt.com'),
|
|
||||||
domain('oaistatic.com'),
|
|
||||||
domain('oaiusercontent.com'),
|
|
||||||
domain('sora.com'),
|
|
||||||
],
|
|
||||||
spotify: [
|
|
||||||
domain('spotify.com'),
|
|
||||||
domain('scdn.co'),
|
|
||||||
domain('spotifycdn.com'),
|
|
||||||
domain('spoti.fi'),
|
|
||||||
domain('spotifycdn.net'),
|
|
||||||
],
|
|
||||||
steam: [
|
|
||||||
domain('steampowered.com'),
|
|
||||||
domain('steamcommunity.com'),
|
|
||||||
domain('steamstatic.com'),
|
|
||||||
domain('steamcontent.com'),
|
|
||||||
domain('valvesoftware.com'),
|
|
||||||
],
|
|
||||||
telegram: [
|
|
||||||
domain('telegram.org'),
|
|
||||||
domain('telegram.me'),
|
|
||||||
domain('t.me'),
|
|
||||||
domain('telesco.pe'),
|
|
||||||
domain('tdesktop.com'),
|
|
||||||
domain('telegra.ph'),
|
|
||||||
domain('cdn-telegram.org'),
|
|
||||||
full('comments.app'),
|
|
||||||
keyword('telegram'),
|
|
||||||
],
|
|
||||||
tiktok: [
|
|
||||||
domain('tiktok.com'),
|
|
||||||
domain('tiktokcdn.com'),
|
|
||||||
domain('tiktokv.com'),
|
|
||||||
domain('byteoversea.com'),
|
|
||||||
domain('ibytedtos.com'),
|
|
||||||
domain('musical.ly'),
|
|
||||||
],
|
|
||||||
twitch: [domain('twitch.tv'), domain('ttvnw.net'), domain('jtvnw.net'), domain('twitchcdn.net')],
|
|
||||||
twitter: [
|
|
||||||
domain('twitter.com'),
|
|
||||||
domain('x.com'),
|
|
||||||
domain('t.co'),
|
|
||||||
domain('twimg.com'),
|
|
||||||
domain('periscope.tv'),
|
|
||||||
],
|
|
||||||
whatsapp: [domain('whatsapp.com'), domain('whatsapp.net'), domain('wa.me')],
|
|
||||||
youtube: [
|
|
||||||
domain('youtube.com'),
|
|
||||||
domain('youtu.be'),
|
|
||||||
domain('ytimg.com'),
|
|
||||||
domain('googlevideo.com'),
|
|
||||||
domain('youtube-nocookie.com'),
|
|
||||||
domain('yt.be'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const SITE_ATTRIBUTES: Record<string, string[]> = {
|
|
||||||
amazon: ['ads'],
|
|
||||||
apple: ['cn'],
|
|
||||||
facebook: ['ads'],
|
|
||||||
google: ['ads', 'cn'],
|
|
||||||
instagram: ['ads'],
|
|
||||||
microsoft: ['cn'],
|
|
||||||
tiktok: ['ads', 'cn'],
|
|
||||||
twitter: ['ads'],
|
|
||||||
youtube: ['ads'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const CN_BLOCKS = [
|
|
||||||
'1.0.1.0/24',
|
|
||||||
'1.0.2.0/23',
|
|
||||||
'1.0.8.0/21',
|
|
||||||
'14.0.12.0/22',
|
|
||||||
'27.0.128.0/21',
|
|
||||||
'36.0.0.0/22',
|
|
||||||
'39.0.0.0/24',
|
|
||||||
'42.0.0.0/22',
|
|
||||||
'58.14.0.0/15',
|
|
||||||
'59.32.0.0/11',
|
|
||||||
'61.128.0.0/10',
|
|
||||||
'101.16.0.0/12',
|
|
||||||
'103.1.8.0/22',
|
|
||||||
'106.0.0.0/10',
|
|
||||||
'110.6.0.0/15',
|
|
||||||
'111.0.0.0/10',
|
|
||||||
'112.0.0.0/10',
|
|
||||||
'113.0.0.0/9',
|
|
||||||
'114.28.0.0/16',
|
|
||||||
'116.0.0.0/9',
|
|
||||||
'117.8.0.0/13',
|
|
||||||
'118.24.0.0/15',
|
|
||||||
'119.0.0.0/9',
|
|
||||||
'120.0.0.0/10',
|
|
||||||
'121.0.0.0/8',
|
|
||||||
'124.0.0.0/8',
|
|
||||||
'125.32.0.0/11',
|
|
||||||
'139.196.0.0/14',
|
|
||||||
'140.75.0.0/16',
|
|
||||||
'175.0.0.0/12',
|
|
||||||
'180.76.0.0/16',
|
|
||||||
'182.16.0.0/12',
|
|
||||||
'183.0.0.0/10',
|
|
||||||
'202.0.0.0/12',
|
|
||||||
'203.0.0.0/12',
|
|
||||||
'210.0.0.0/12',
|
|
||||||
'211.64.0.0/11',
|
|
||||||
'218.0.0.0/9',
|
|
||||||
'219.72.0.0/14',
|
|
||||||
'220.112.0.0/12',
|
|
||||||
'221.0.0.0/9',
|
|
||||||
'222.16.0.0/12',
|
|
||||||
'2001:250::/35',
|
|
||||||
'2400:3200::/32',
|
|
||||||
'2408:8000::/20',
|
|
||||||
];
|
|
||||||
|
|
||||||
const CN_EXTRA_BLOCKS = Array.from(
|
|
||||||
{ length: 96 },
|
|
||||||
(_, index) => `${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const IP_ENTRIES: Record<string, GeoEntry[]> = {
|
|
||||||
cloudflare: [
|
|
||||||
'103.21.244.0/22',
|
|
||||||
'103.22.200.0/22',
|
|
||||||
'103.31.4.0/22',
|
|
||||||
'104.16.0.0/13',
|
|
||||||
'104.24.0.0/14',
|
|
||||||
'108.162.192.0/18',
|
|
||||||
'131.0.72.0/22',
|
|
||||||
'141.101.64.0/18',
|
|
||||||
'162.158.0.0/15',
|
|
||||||
'172.64.0.0/13',
|
|
||||||
'173.245.48.0/20',
|
|
||||||
'188.114.96.0/20',
|
|
||||||
'190.93.240.0/20',
|
|
||||||
'197.234.240.0/22',
|
|
||||||
'198.41.128.0/17',
|
|
||||||
'2400:cb00::/32',
|
|
||||||
'2606:4700::/32',
|
|
||||||
].map(cidr),
|
|
||||||
cn: [...CN_BLOCKS, ...CN_EXTRA_BLOCKS].map(cidr),
|
|
||||||
facebook: [
|
|
||||||
'31.13.24.0/21',
|
|
||||||
'31.13.64.0/18',
|
|
||||||
'66.220.144.0/20',
|
|
||||||
'69.63.176.0/20',
|
|
||||||
'69.171.224.0/19',
|
|
||||||
'157.240.0.0/16',
|
|
||||||
'179.60.192.0/22',
|
|
||||||
'185.60.216.0/22',
|
|
||||||
'2a03:2880::/32',
|
|
||||||
].map(cidr),
|
|
||||||
google: [
|
|
||||||
'8.8.4.0/24',
|
|
||||||
'8.8.8.0/24',
|
|
||||||
'34.64.0.0/10',
|
|
||||||
'35.184.0.0/13',
|
|
||||||
'64.233.160.0/19',
|
|
||||||
'66.102.0.0/20',
|
|
||||||
'72.14.192.0/18',
|
|
||||||
'74.125.0.0/16',
|
|
||||||
'108.177.8.0/21',
|
|
||||||
'142.250.0.0/15',
|
|
||||||
'172.217.0.0/16',
|
|
||||||
'216.58.192.0/19',
|
|
||||||
'2404:6800::/32',
|
|
||||||
'2607:f8b0::/32',
|
|
||||||
].map(cidr),
|
|
||||||
ir: [
|
|
||||||
'2.144.0.0/14',
|
|
||||||
'5.22.0.0/17',
|
|
||||||
'31.2.128.0/17',
|
|
||||||
'37.32.0.0/19',
|
|
||||||
'46.32.0.0/19',
|
|
||||||
'78.38.0.0/15',
|
|
||||||
'80.191.0.0/16',
|
|
||||||
'85.15.0.0/18',
|
|
||||||
'91.98.0.0/15',
|
|
||||||
'178.22.72.0/21',
|
|
||||||
'185.8.172.0/22',
|
|
||||||
'188.34.0.0/17',
|
|
||||||
'217.218.0.0/15',
|
|
||||||
].map(cidr),
|
|
||||||
netflix: [
|
|
||||||
'23.246.0.0/18',
|
|
||||||
'37.77.184.0/21',
|
|
||||||
'45.57.0.0/17',
|
|
||||||
'64.120.128.0/17',
|
|
||||||
'66.197.128.0/17',
|
|
||||||
'108.175.32.0/20',
|
|
||||||
'185.2.220.0/22',
|
|
||||||
'192.173.64.0/18',
|
|
||||||
'198.38.96.0/19',
|
|
||||||
'198.45.48.0/20',
|
|
||||||
].map(cidr),
|
|
||||||
private: [
|
|
||||||
'0.0.0.0/8',
|
|
||||||
'10.0.0.0/8',
|
|
||||||
'100.64.0.0/10',
|
|
||||||
'127.0.0.0/8',
|
|
||||||
'169.254.0.0/16',
|
|
||||||
'172.16.0.0/12',
|
|
||||||
'192.0.0.0/24',
|
|
||||||
'192.0.2.0/24',
|
|
||||||
'192.168.0.0/16',
|
|
||||||
'198.18.0.0/15',
|
|
||||||
'198.51.100.0/24',
|
|
||||||
'203.0.113.0/24',
|
|
||||||
'224.0.0.0/4',
|
|
||||||
'240.0.0.0/4',
|
|
||||||
'255.255.255.255/32',
|
|
||||||
'::1/128',
|
|
||||||
'fc00::/7',
|
|
||||||
'fe80::/10',
|
|
||||||
].map(cidr),
|
|
||||||
ru: [
|
|
||||||
'2.60.0.0/14',
|
|
||||||
'5.8.0.0/19',
|
|
||||||
'31.6.0.0/17',
|
|
||||||
'37.9.0.0/19',
|
|
||||||
'46.16.0.0/21',
|
|
||||||
'62.76.0.0/18',
|
|
||||||
'77.37.128.0/17',
|
|
||||||
'78.24.216.0/21',
|
|
||||||
'79.104.0.0/15',
|
|
||||||
'80.64.128.0/19',
|
|
||||||
'81.16.96.0/19',
|
|
||||||
'82.140.128.0/18',
|
|
||||||
'85.113.0.0/16',
|
|
||||||
'87.226.0.0/16',
|
|
||||||
'91.77.0.0/16',
|
|
||||||
'93.157.0.0/17',
|
|
||||||
'94.19.0.0/16',
|
|
||||||
'95.24.0.0/13',
|
|
||||||
'178.176.0.0/13',
|
|
||||||
'188.128.0.0/13',
|
|
||||||
'213.87.0.0/16',
|
|
||||||
'217.66.152.0/21',
|
|
||||||
'2a00:1148::/32',
|
|
||||||
].map(cidr),
|
|
||||||
telegram: [
|
|
||||||
'91.108.4.0/22',
|
|
||||||
'91.108.8.0/22',
|
|
||||||
'91.108.12.0/22',
|
|
||||||
'91.108.16.0/22',
|
|
||||||
'91.108.20.0/22',
|
|
||||||
'91.108.56.0/22',
|
|
||||||
'149.154.160.0/20',
|
|
||||||
'2001:67c:4e8::/48',
|
|
||||||
'2001:b28:f23d::/48',
|
|
||||||
'2001:b28:f23f::/48',
|
|
||||||
].map(cidr),
|
|
||||||
us: [
|
|
||||||
'3.0.0.0/9',
|
|
||||||
'12.0.0.0/8',
|
|
||||||
'23.192.0.0/11',
|
|
||||||
'34.192.0.0/10',
|
|
||||||
'50.16.0.0/14',
|
|
||||||
'52.0.0.0/10',
|
|
||||||
'63.64.0.0/11',
|
|
||||||
'65.0.0.0/10',
|
|
||||||
'68.32.0.0/11',
|
|
||||||
'71.0.0.0/11',
|
|
||||||
'96.0.0.0/9',
|
|
||||||
'128.0.0.0/10',
|
|
||||||
'199.0.0.0/12',
|
|
||||||
'208.64.0.0/12',
|
|
||||||
'2600:1f00::/24',
|
|
||||||
].map(cidr),
|
|
||||||
};
|
|
||||||
|
|
||||||
function categoriesOf(
|
|
||||||
entries: Record<string, GeoEntry[]>,
|
|
||||||
attributes: Record<string, string[]> = {},
|
|
||||||
): GeoCategory[] {
|
|
||||||
return Object.keys(entries)
|
|
||||||
.sort()
|
|
||||||
.map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const SITE_CATEGORIES = categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES);
|
|
||||||
const IP_CATEGORIES = categoriesOf(IP_ENTRIES);
|
|
||||||
|
|
||||||
const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
|
|
||||||
|
|
||||||
const GEOSITE_FILE: GeoFile = {
|
|
||||||
name: 'geosite.dat',
|
|
||||||
kind: 'site',
|
|
||||||
size: 4_812_544,
|
|
||||||
modifiedAt: UPDATED_AT,
|
|
||||||
categories: SITE_CATEGORIES.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const GEOIP_FILE: GeoFile = {
|
|
||||||
name: 'geoip.dat',
|
|
||||||
kind: 'ip',
|
|
||||||
size: 8_694_272,
|
|
||||||
modifiedAt: UPDATED_AT,
|
|
||||||
categories: IP_CATEGORIES.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DAMAGED_FILE: GeoFile = {
|
|
||||||
name: 'geosite-custom.dat',
|
|
||||||
kind: 'site',
|
|
||||||
size: 262_144,
|
|
||||||
modifiedAt: Date.UTC(2026, 5, 2, 19, 45),
|
|
||||||
categories: 0,
|
|
||||||
error: 'proto: cannot parse invalid wire-format data',
|
|
||||||
};
|
|
||||||
|
|
||||||
const OVERSIZED_FILE: GeoFile = {
|
|
||||||
name: 'geoip-full.dat',
|
|
||||||
kind: 'ip',
|
|
||||||
size: 96_468_992,
|
|
||||||
modifiedAt: Date.UTC(2026, 6, 20, 8, 5),
|
|
||||||
categories: 0,
|
|
||||||
error: 'geodata file is too large to browse',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
|
|
||||||
{
|
|
||||||
'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES },
|
|
||||||
'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES },
|
|
||||||
};
|
|
||||||
|
|
||||||
function routesFor(files: GeoFile[]): GeoRoutes {
|
|
||||||
return {
|
|
||||||
'/panel/api/xray/geodata/files': () => files,
|
|
||||||
'/panel/api/xray/geodata/categories': (query) => {
|
|
||||||
const dataset = DATASETS[query.get('file') ?? ''];
|
|
||||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
|
||||||
const items = (dataset?.categories ?? []).filter((category) =>
|
|
||||||
category.code.includes(needle),
|
|
||||||
);
|
|
||||||
return { total: items.length, items };
|
|
||||||
},
|
|
||||||
'/panel/api/xray/geodata/entries': (query) => {
|
|
||||||
const dataset = DATASETS[query.get('file') ?? ''];
|
|
||||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
|
||||||
const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
|
|
||||||
entry.value.toLowerCase().includes(needle),
|
|
||||||
);
|
|
||||||
const offset = Number(query.get('offset') ?? 0);
|
|
||||||
const limit = Number(query.get('limit') ?? 100);
|
|
||||||
return { total: matched.length, items: matched.slice(offset, offset + limit) };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function withFiles(files: GeoFile[]): Decorator {
|
|
||||||
const routes = routesFor(files);
|
|
||||||
return function GeodataBackend(Story) {
|
|
||||||
return (
|
|
||||||
<GeoApi routes={routes}>
|
|
||||||
<Story />
|
|
||||||
</GeoApi>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]);
|
|
||||||
|
|
||||||
function BrowserDemo(props: GeoBrowserModalProps) {
|
|
||||||
const [open, setOpen] = useState(props.open);
|
|
||||||
const [value, setValue] = useState(props.value);
|
|
||||||
const [synced, setSynced] = useState({ open: props.open, value: props.value });
|
|
||||||
if (synced.open !== props.open || synced.value !== props.value) {
|
|
||||||
setSynced({ open: props.open, value: props.value });
|
|
||||||
setOpen(props.open);
|
|
||||||
setValue(props.value);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Space orientation="vertical" size={12}>
|
|
||||||
<Space size={8}>
|
|
||||||
<Button onClick={() => setOpen(true)}>Open geo browser</Button>
|
|
||||||
<Typography.Text code>{value || 'no rule yet'}</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
<GeoBrowserModal
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
value={value}
|
|
||||||
onApply={(next) => {
|
|
||||||
setValue(next);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Geodata/GeoBrowserModal',
|
|
||||||
component: GeoBrowserModal,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'padded',
|
|
||||||
a11y: {
|
|
||||||
config: {
|
|
||||||
rules: [{ id: 'color-contrast', enabled: false }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component:
|
|
||||||
'Browser for the geosite/geoip `.dat` databases Xray resolves `geosite:` and `geoip:` routing tokens against: pick a database, search its categories, tick the ones a rule needs, and preview the domains or CIDRs inside the highlighted category. Applying merges the ticked categories back into the rule string, keeping hand-typed domains untouched. The stories serve `/panel/api/xray/geodata/*` from an in-memory fixture, so search, paging and selection all work without a panel backend.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
args: {
|
|
||||||
open: true,
|
|
||||||
kind: 'site',
|
|
||||||
value: '',
|
|
||||||
onApply: () => undefined,
|
|
||||||
onClose: () => undefined,
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
open: { description: 'Whether the modal is visible.' },
|
|
||||||
kind: {
|
|
||||||
description:
|
|
||||||
'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.',
|
|
||||||
control: 'inline-radio',
|
|
||||||
options: ['site', 'ip'],
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
description:
|
|
||||||
'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.',
|
|
||||||
},
|
|
||||||
onApply: { description: 'Called with the merged rule string when Apply is pressed.' },
|
|
||||||
onClose: { description: 'Called when the modal is dismissed.' },
|
|
||||||
},
|
|
||||||
render: (args) => <BrowserDemo {...args} />,
|
|
||||||
} satisfies Meta<typeof GeoBrowserModal>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
export const SiteDatabase: Story = {
|
|
||||||
decorators: [withDatabases],
|
|
||||||
args: { kind: 'site', value: 'geosite:google, geosite:telegram, ads.example.com' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CategoryPreview: Story = {
|
|
||||||
decorators: [withDatabases],
|
|
||||||
args: { kind: 'site', value: 'geosite:google' },
|
|
||||||
parameters: {
|
|
||||||
a11y: {
|
|
||||||
config: {
|
|
||||||
rules: [
|
|
||||||
{ id: 'color-contrast', enabled: false },
|
|
||||||
{ id: 'scrollable-region-focusable', enabled: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
play: async ({ canvasElement, userEvent }) => {
|
|
||||||
const body = within(canvasElement.ownerDocument.body);
|
|
||||||
await userEvent.type(await body.findByPlaceholderText('Search category'), 'telegram');
|
|
||||||
await userEvent.click(await body.findByText('telegram'));
|
|
||||||
await expect(await body.findByText('t.me')).toBeVisible();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IpDatabase: Story = {
|
|
||||||
decorators: [withDatabases],
|
|
||||||
args: { kind: 'ip', value: 'geoip:private, 10.0.0.0/8' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NoDatabases: Story = {
|
|
||||||
decorators: [withFiles([])],
|
|
||||||
args: { kind: 'site', value: 'geosite:google' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DamagedDatabase: Story = {
|
|
||||||
decorators: [withFiles([GEOSITE_FILE, DAMAGED_FILE, OVERSIZED_FILE])],
|
|
||||||
args: { kind: 'site', value: '' },
|
|
||||||
};
|
|
||||||
@@ -1,473 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Empty,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
Pagination,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
|
|
||||||
import { useGeodataCategories, useGeodataEntries, useGeodataFiles } from '@/api/queries/useGeodata';
|
|
||||||
import { canonicalToken, mergeSelection, selectionFromValue, tokenFor } from '@/lib/xray/geoTokens';
|
|
||||||
import { SizeFormatter } from '@/utils';
|
|
||||||
import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types';
|
|
||||||
|
|
||||||
import './GeoBrowserModal.css';
|
|
||||||
|
|
||||||
const ENTRY_PAGE_SIZE = 100;
|
|
||||||
const CATEGORY_SCROLL_HEIGHT = 438;
|
|
||||||
const ENTRY_FILTER_DELAY = 500;
|
|
||||||
|
|
||||||
export interface GeoBrowserModalProps {
|
|
||||||
open: boolean;
|
|
||||||
kind: GeoKind;
|
|
||||||
value: string;
|
|
||||||
onApply: (value: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A geosite category inside an ip rule (or the reverse) is a config Xray will
|
|
||||||
// reject, so a field only ever offers databases of its own kind.
|
|
||||||
function databasesFor(files: GeoFile[], kind: GeoKind): GeoFile[] {
|
|
||||||
return files.filter(
|
|
||||||
(file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function namePrefersKind(name: string, kind: GeoKind): boolean {
|
|
||||||
return name.toLowerCase().includes('ip') === (kind === 'ip');
|
|
||||||
}
|
|
||||||
|
|
||||||
function preferredFile(files: GeoFile[], kind: GeoKind): string | undefined {
|
|
||||||
const usable = databasesFor(files, kind).filter((file) => !file.error);
|
|
||||||
const preferredName = kind === 'ip' ? 'geoip.dat' : 'geosite.dat';
|
|
||||||
return usable.find((file) => file.name === preferredName)?.name ?? usable[0]?.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GeoBrowserModal({
|
|
||||||
open,
|
|
||||||
kind,
|
|
||||||
value,
|
|
||||||
onApply,
|
|
||||||
onClose,
|
|
||||||
}: GeoBrowserModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [file, setFile] = useState<string | undefined>(undefined);
|
|
||||||
const [categoryQuery, setCategoryQuery] = useState('');
|
|
||||||
const [activeCode, setActiveCode] = useState<string | undefined>(undefined);
|
|
||||||
const [entryQuery, setEntryQuery] = useState('');
|
|
||||||
const [entryFilter, setEntryFilter] = useState('');
|
|
||||||
const [entryPage, setEntryPage] = useState(1);
|
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const [known, setKnown] = useState<Set<string>>(() => new Set());
|
|
||||||
const [seededFiles, setSeededFiles] = useState<Set<string>>(() => new Set());
|
|
||||||
|
|
||||||
const filesQuery = useGeodataFiles(open);
|
|
||||||
const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]);
|
|
||||||
const activeFile = useMemo(
|
|
||||||
() => files.find((candidate) => candidate.name === file),
|
|
||||||
[files, file],
|
|
||||||
);
|
|
||||||
const fileKind: GeoKind = useMemo(() => activeFile?.kind ?? kind, [activeFile, kind]);
|
|
||||||
|
|
||||||
const categoriesQuery = useGeodataCategories(file, '', open && !!file);
|
|
||||||
// While a newly picked database loads, the query still serves the previous
|
|
||||||
// one's categories; seeding or filtering against those would attribute one
|
|
||||||
// database's codes to another.
|
|
||||||
const categoriesLoaded = !categoriesQuery.isPlaceholderData && !categoriesQuery.isLoading;
|
|
||||||
const categories = useMemo(
|
|
||||||
() => (categoriesLoaded ? (categoriesQuery.data?.items ?? []) : []),
|
|
||||||
[categoriesLoaded, categoriesQuery.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only the settled filter reaches the query key: every request rescans the
|
|
||||||
// whole .dat file server-side, so a per-keystroke fetch would be one full
|
|
||||||
// scan per character while the box itself stays instant.
|
|
||||||
const entriesQuery = useGeodataEntries(
|
|
||||||
file,
|
|
||||||
activeCode,
|
|
||||||
entryFilter,
|
|
||||||
(entryPage - 1) * ENTRY_PAGE_SIZE,
|
|
||||||
ENTRY_PAGE_SIZE,
|
|
||||||
open && !!file && !!activeCode,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Resets clear both halves at once so a switch of database or category never
|
|
||||||
// renders with the previous filter still in the key, which would fire the
|
|
||||||
// very request the debounce exists to avoid.
|
|
||||||
const clearEntryFilter = useCallback(() => {
|
|
||||||
setEntryQuery('');
|
|
||||||
setEntryFilter('');
|
|
||||||
setEntryPage(1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (entryQuery === entryFilter) return;
|
|
||||||
const handle = window.setTimeout(() => {
|
|
||||||
setEntryFilter(entryQuery);
|
|
||||||
setEntryPage(1);
|
|
||||||
}, ENTRY_FILTER_DELAY);
|
|
||||||
return () => window.clearTimeout(handle);
|
|
||||||
}, [entryQuery, entryFilter]);
|
|
||||||
|
|
||||||
// Opening, picking the default database and seeding the selection are all
|
|
||||||
// render-time adjustments — an effect would paint the previous state first.
|
|
||||||
const [wasOpen, setWasOpen] = useState(false);
|
|
||||||
if (open !== wasOpen) {
|
|
||||||
setWasOpen(open);
|
|
||||||
if (open) {
|
|
||||||
setKnown(new Set());
|
|
||||||
setSeededFiles(new Set());
|
|
||||||
setCategoryQuery('');
|
|
||||||
setEntryQuery('');
|
|
||||||
setEntryFilter('');
|
|
||||||
setActiveCode(undefined);
|
|
||||||
setEntryPage(1);
|
|
||||||
setSelected([]);
|
|
||||||
}
|
|
||||||
} else if (open && !file && files.length > 0) {
|
|
||||||
setFile(preferredFile(files, kind));
|
|
||||||
} else if (open && file && categories.length > 0 && !seededFiles.has(file)) {
|
|
||||||
const tokens = categories.map((category) => tokenFor(file, category.code, fileKind));
|
|
||||||
setKnown(new Set([...known, ...tokens]));
|
|
||||||
setSeededFiles(new Set(seededFiles).add(file));
|
|
||||||
const fromValue = selectionFromValue(value, new Set(tokens));
|
|
||||||
if (fromValue.length > 0) {
|
|
||||||
setSelected((previous) => [
|
|
||||||
...previous,
|
|
||||||
...fromValue.filter((token) => !previous.includes(token)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleCategories = useMemo(() => {
|
|
||||||
const query = categoryQuery.trim().toLowerCase();
|
|
||||||
if (!query) return categories;
|
|
||||||
return categories.filter((category) => category.code.includes(query));
|
|
||||||
}, [categories, categoryQuery]);
|
|
||||||
|
|
||||||
// Comparisons run through the canonical form: a field may hold the long
|
|
||||||
// ext:geosite.dat:cn spelling or a different case, and those name the same
|
|
||||||
// category as the geosite:cn this modal generates.
|
|
||||||
const selectedCodes = useMemo(() => {
|
|
||||||
if (!file) return [];
|
|
||||||
const chosen = new Set(selected.map(canonicalToken));
|
|
||||||
return categories
|
|
||||||
.filter((category) => chosen.has(canonicalToken(tokenFor(file, category.code, fileKind))))
|
|
||||||
.map((category) => category.code);
|
|
||||||
}, [categories, file, fileKind, selected]);
|
|
||||||
|
|
||||||
const toggle = useCallback(
|
|
||||||
(codes: string[]) => {
|
|
||||||
if (!file) return;
|
|
||||||
const chosen = new Set(codes.map((code) => tokenFor(file, code, fileKind)));
|
|
||||||
const chosenCanonical = new Set([...chosen].map(canonicalToken));
|
|
||||||
// The table reports keys for the rows it currently shows, so a selection
|
|
||||||
// made before the search box was narrowed must survive untouched.
|
|
||||||
const shown = new Set(
|
|
||||||
visibleCategories.map((category) =>
|
|
||||||
canonicalToken(tokenFor(file, category.code, fileKind)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setSelected((previous) => {
|
|
||||||
const kept = previous.filter((token) => {
|
|
||||||
const canonical = canonicalToken(token);
|
|
||||||
return !shown.has(canonical) || chosenCanonical.has(canonical);
|
|
||||||
});
|
|
||||||
const keptCanonical = new Set(kept.map(canonicalToken));
|
|
||||||
return [
|
|
||||||
...kept,
|
|
||||||
...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token))),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[visibleCategories, file, fileKind],
|
|
||||||
);
|
|
||||||
|
|
||||||
const categoryColumns: ColumnsType<GeoCategory> = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
title: t('pages.xray.geoBrowser.searchCategory'),
|
|
||||||
dataIndex: 'code',
|
|
||||||
render: (code: string, category: GeoCategory) => (
|
|
||||||
<span className="geo-category">
|
|
||||||
<span className="geo-code">{code}</span>
|
|
||||||
{category.attributes?.length > 0 && (
|
|
||||||
<span className="geo-attrs">
|
|
||||||
{category.attributes.map((attribute) => (
|
|
||||||
<Tag key={attribute} variant="filled">
|
|
||||||
@{attribute}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dataIndex: 'entries',
|
|
||||||
align: 'right',
|
|
||||||
width: 90,
|
|
||||||
render: (entries: number) => <span className="geo-count">{entries.toLocaleString()}</span>,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const entryColumns: ColumnsType<GeoEntry> = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
dataIndex: 'kind',
|
|
||||||
width: 88,
|
|
||||||
render: (entryKind: string) => (
|
|
||||||
<Tag variant="filled" className={`geo-kind geo-kind-${entryKind}`}>
|
|
||||||
{entryKind}
|
|
||||||
</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dataIndex: 'value',
|
|
||||||
render: (entryValue: string) => <span className="geo-entry-value">{entryValue}</span>,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const fileOptions = files.map((candidate) => ({
|
|
||||||
value: candidate.name,
|
|
||||||
label: candidate.error
|
|
||||||
? `${candidate.name} — ${describeFileError(candidate.error, t)}`
|
|
||||||
: candidate.name,
|
|
||||||
disabled: !!candidate.error,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const meta = activeFile
|
|
||||||
? t('pages.xray.geoBrowser.fileMeta', {
|
|
||||||
count: activeFile.categories.toLocaleString(),
|
|
||||||
size: SizeFormatter.sizeFormat(activeFile.size),
|
|
||||||
date: new Date(activeFile.modifiedAt).toLocaleString(),
|
|
||||||
})
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const entriesTotal = entriesQuery.data?.total ?? 0;
|
|
||||||
const activeCategory = categories.find((category) => category.code === activeCode);
|
|
||||||
const countLabel = activeCategory
|
|
||||||
? t(
|
|
||||||
fileKind === 'ip'
|
|
||||||
? 'pages.xray.geoBrowser.subnetsCount'
|
|
||||||
: 'pages.xray.geoBrowser.entriesCount',
|
|
||||||
{
|
|
||||||
count: activeCategory.entries.toLocaleString(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
title={t('pages.xray.geoBrowser.title')}
|
|
||||||
width={880}
|
|
||||||
onCancel={onClose}
|
|
||||||
onOk={() => onApply(mergeSelection(value, selected, known))}
|
|
||||||
okText={t('pages.xray.geoBrowser.apply')}
|
|
||||||
cancelText={t('close')}
|
|
||||||
className="geo-browser-modal"
|
|
||||||
>
|
|
||||||
{filesQuery.isError && (
|
|
||||||
<Alert
|
|
||||||
type="error"
|
|
||||||
showIcon
|
|
||||||
title={t('pages.xray.geoBrowser.loadFailed')}
|
|
||||||
className="mb-12"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? (
|
|
||||||
<Empty
|
|
||||||
description={
|
|
||||||
<span>
|
|
||||||
{t('pages.xray.geoBrowser.noFiles')}
|
|
||||||
<br />
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{t('pages.xray.geoBrowser.noFilesHint')}
|
|
||||||
</Typography.Text>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="geo-toolbar">
|
|
||||||
<Select
|
|
||||||
value={file}
|
|
||||||
options={fileOptions}
|
|
||||||
onChange={(next) => {
|
|
||||||
setFile(next);
|
|
||||||
setActiveCode(undefined);
|
|
||||||
setCategoryQuery('');
|
|
||||||
clearEntryFilter();
|
|
||||||
}}
|
|
||||||
style={{ minWidth: 200 }}
|
|
||||||
aria-label={t('pages.xray.geoBrowser.database')}
|
|
||||||
/>
|
|
||||||
<Input.Search
|
|
||||||
value={categoryQuery}
|
|
||||||
onChange={(event) => setCategoryQuery(event.target.value)}
|
|
||||||
placeholder={t('pages.xray.geoBrowser.searchCategory')}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])
|
|
||||||
}
|
|
||||||
disabled={visibleCategories.length === 0}
|
|
||||||
>
|
|
||||||
{`${t('pages.xray.geoBrowser.selectFound')} (${visibleCategories.length.toLocaleString()})`}
|
|
||||||
</Button>
|
|
||||||
<span className="geo-meta">{meta}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="geo-columns">
|
|
||||||
<div className="geo-panel geo-categories">
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
virtual
|
|
||||||
showHeader={false}
|
|
||||||
rowKey="code"
|
|
||||||
columns={categoryColumns}
|
|
||||||
dataSource={visibleCategories}
|
|
||||||
loading={
|
|
||||||
filesQuery.isLoading ||
|
|
||||||
categoriesQuery.isLoading ||
|
|
||||||
categoriesQuery.isPlaceholderData
|
|
||||||
}
|
|
||||||
pagination={false}
|
|
||||||
scroll={{ y: CATEGORY_SCROLL_HEIGHT }}
|
|
||||||
locale={{ emptyText: t('pages.xray.geoBrowser.noMatches') }}
|
|
||||||
rowSelection={{
|
|
||||||
columnWidth: 42,
|
|
||||||
preserveSelectedRowKeys: true,
|
|
||||||
selectedRowKeys: selectedCodes,
|
|
||||||
onChange: (keys) => toggle(keys as string[]),
|
|
||||||
}}
|
|
||||||
onRow={(category) => ({
|
|
||||||
onClick: (event) => {
|
|
||||||
if ((event.target as HTMLElement).closest('.ant-table-selection-column'))
|
|
||||||
return;
|
|
||||||
setActiveCode(category.code);
|
|
||||||
clearEntryFilter();
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
rowClassName={(category) => (category.code === activeCode ? 'geo-row-active' : '')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="geo-panel geo-preview">
|
|
||||||
{activeCode ? (
|
|
||||||
<>
|
|
||||||
<div className="geo-preview-head">
|
|
||||||
<Tooltip title={file ? tokenFor(file, activeCode, fileKind) : activeCode}>
|
|
||||||
<span className="geo-preview-title">{activeCode}</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Typography.Text type="secondary">{countLabel}</Typography.Text>
|
|
||||||
<Input
|
|
||||||
value={entryQuery}
|
|
||||||
onChange={(event) => setEntryQuery(event.target.value)}
|
|
||||||
placeholder={t('pages.xray.geoBrowser.searchEntries')}
|
|
||||||
allowClear
|
|
||||||
className="geo-entry-filter"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="geo-preview-body">
|
|
||||||
<Table
|
|
||||||
size="small"
|
|
||||||
showHeader={false}
|
|
||||||
rowKey={(entry, index) => `${entry.value}-${index}`}
|
|
||||||
columns={entryColumns}
|
|
||||||
dataSource={entriesQuery.data?.items ?? []}
|
|
||||||
loading={entriesQuery.isLoading}
|
|
||||||
locale={{
|
|
||||||
emptyText: entriesQuery.isError
|
|
||||||
? t('pages.xray.geoBrowser.loadFailed')
|
|
||||||
: t('pages.xray.geoBrowser.noMatches'),
|
|
||||||
}}
|
|
||||||
pagination={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="geo-pager">
|
|
||||||
<Pagination
|
|
||||||
current={entryPage}
|
|
||||||
pageSize={ENTRY_PAGE_SIZE}
|
|
||||||
total={entriesTotal}
|
|
||||||
size="small"
|
|
||||||
showSizeChanger={false}
|
|
||||||
onChange={setEntryPage}
|
|
||||||
showTotal={(total, range) =>
|
|
||||||
t('pages.xray.geoBrowser.shownRange', {
|
|
||||||
from: range[0].toLocaleString(),
|
|
||||||
to: range[1].toLocaleString(),
|
|
||||||
total: total.toLocaleString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="geo-placeholder">
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{t('pages.xray.geoBrowser.pickCategory')}
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="geo-footer">
|
|
||||||
{selected.length === 0 ? (
|
|
||||||
<Typography.Text type="secondary">
|
|
||||||
{t('pages.xray.geoBrowser.emptySelection')}
|
|
||||||
</Typography.Text>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Space size={4} wrap className="geo-chips">
|
|
||||||
{selected.map((token) => (
|
|
||||||
<Tag
|
|
||||||
key={token}
|
|
||||||
closable
|
|
||||||
color="processing"
|
|
||||||
onClose={() =>
|
|
||||||
setSelected((previous) => previous.filter((item) => item !== token))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{token}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
<span className="geo-selected-count">
|
|
||||||
{t('pages.xray.geoBrowser.selected', { count: selected.length })}
|
|
||||||
</span>
|
|
||||||
<Button type="link" size="small" onClick={() => setSelected([])}>
|
|
||||||
{t('pages.xray.geoBrowser.clearAll')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeFileError(error: string, t: (key: string) => string): string {
|
|
||||||
if (error.includes('too large')) return t('pages.xray.geoBrowser.tooLarge');
|
|
||||||
return t('pages.xray.geoBrowser.parseFailed');
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
|
||||||
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { expect, within } from 'storybook/test';
|
|
||||||
import { Space } from 'antd';
|
|
||||||
|
|
||||||
import { parseTokens } from '@/lib/xray/geoTokens';
|
|
||||||
import type { GeoCategory, GeoEntry, GeoFile, GeodataTokenIssue } from '@/generated/types';
|
|
||||||
|
|
||||||
import GeoTokenInput, { type GeoTokenInputProps } from './GeoTokenInput';
|
|
||||||
|
|
||||||
type GeoResponder = (query: URLSearchParams, body: URLSearchParams) => unknown;
|
|
||||||
type GeoRoutes = Record<string, GeoResponder>;
|
|
||||||
|
|
||||||
const realFetch = window.fetch.bind(window);
|
|
||||||
let activeRoutes: GeoRoutes = {};
|
|
||||||
|
|
||||||
function requestUrl(input: RequestInfo | URL): URL {
|
|
||||||
if (typeof input === 'string') return new URL(input, window.location.origin);
|
|
||||||
if (input instanceof URL) return input;
|
|
||||||
return new URL(input.url, window.location.origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
||||||
const url = requestUrl(input);
|
|
||||||
const responder = activeRoutes[url.pathname];
|
|
||||||
if (!responder) return realFetch(input, init);
|
|
||||||
const form = new URLSearchParams(typeof init?.body === 'string' ? init.body : '');
|
|
||||||
const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams, form) });
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function activate(routes: GeoRoutes): void {
|
|
||||||
activeRoutes = routes;
|
|
||||||
window.fetch = geoFetch;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deactivate(routes: GeoRoutes): void {
|
|
||||||
if (activeRoutes === routes) activeRoutes = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
|
|
||||||
const [client] = useState(() => {
|
|
||||||
activate(routes);
|
|
||||||
return new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
activate(routes);
|
|
||||||
return () => deactivate(routes);
|
|
||||||
}, [routes]);
|
|
||||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
|
|
||||||
const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
|
|
||||||
|
|
||||||
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
|
|
||||||
'category-ads-all': [
|
|
||||||
domain('doubleclick.net'),
|
|
||||||
domain('googleadservices.com'),
|
|
||||||
domain('googlesyndication.com'),
|
|
||||||
domain('criteo.com'),
|
|
||||||
domain('taboola.com'),
|
|
||||||
domain('outbrain.com'),
|
|
||||||
],
|
|
||||||
cn: [
|
|
||||||
domain('baidu.com'),
|
|
||||||
domain('qq.com'),
|
|
||||||
domain('taobao.com'),
|
|
||||||
domain('weibo.com'),
|
|
||||||
domain('bilibili.com'),
|
|
||||||
],
|
|
||||||
google: [
|
|
||||||
domain('google.com'),
|
|
||||||
domain('googleapis.com'),
|
|
||||||
domain('gstatic.com'),
|
|
||||||
domain('googleusercontent.com'),
|
|
||||||
domain('ggpht.com'),
|
|
||||||
domain('android.com'),
|
|
||||||
],
|
|
||||||
netflix: [
|
|
||||||
domain('netflix.com'),
|
|
||||||
domain('nflximg.net'),
|
|
||||||
domain('nflxvideo.net'),
|
|
||||||
domain('fast.com'),
|
|
||||||
],
|
|
||||||
telegram: [domain('telegram.org'), domain('t.me'), domain('telesco.pe'), domain('telegra.ph')],
|
|
||||||
youtube: [
|
|
||||||
domain('youtube.com'),
|
|
||||||
domain('youtu.be'),
|
|
||||||
domain('ytimg.com'),
|
|
||||||
domain('googlevideo.com'),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const IP_ENTRIES: Record<string, GeoEntry[]> = {
|
|
||||||
cloudflare: ['104.16.0.0/13', '172.64.0.0/13', '2606:4700::/32'].map(cidr),
|
|
||||||
cn: ['1.0.1.0/24', '36.0.0.0/22', '116.0.0.0/9', '2408:8000::/20'].map(cidr),
|
|
||||||
private: [
|
|
||||||
'10.0.0.0/8',
|
|
||||||
'127.0.0.0/8',
|
|
||||||
'169.254.0.0/16',
|
|
||||||
'172.16.0.0/12',
|
|
||||||
'192.168.0.0/16',
|
|
||||||
'::1/128',
|
|
||||||
'fc00::/7',
|
|
||||||
'fe80::/10',
|
|
||||||
].map(cidr),
|
|
||||||
telegram: ['91.108.4.0/22', '149.154.160.0/20', '2001:b28:f23d::/48'].map(cidr),
|
|
||||||
};
|
|
||||||
|
|
||||||
const SITE_ATTRIBUTES: Record<string, string[]> = {
|
|
||||||
google: ['ads', 'cn'],
|
|
||||||
youtube: ['ads'],
|
|
||||||
};
|
|
||||||
|
|
||||||
function categoriesOf(
|
|
||||||
entries: Record<string, GeoEntry[]>,
|
|
||||||
attributes: Record<string, string[]> = {},
|
|
||||||
): GeoCategory[] {
|
|
||||||
return Object.keys(entries)
|
|
||||||
.sort()
|
|
||||||
.map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> =
|
|
||||||
{
|
|
||||||
'geosite.dat': {
|
|
||||||
categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES),
|
|
||||||
entries: SITE_ENTRIES,
|
|
||||||
},
|
|
||||||
'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES },
|
|
||||||
};
|
|
||||||
|
|
||||||
const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
|
|
||||||
|
|
||||||
const FILES: GeoFile[] = [
|
|
||||||
{
|
|
||||||
name: 'geosite.dat',
|
|
||||||
kind: 'site',
|
|
||||||
size: 4_812_544,
|
|
||||||
modifiedAt: UPDATED_AT,
|
|
||||||
categories: DATASETS['geosite.dat'].categories.length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'geoip.dat',
|
|
||||||
kind: 'ip',
|
|
||||||
size: 8_694_272,
|
|
||||||
modifiedAt: UPDATED_AT,
|
|
||||||
categories: DATASETS['geoip.dat'].categories.length,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function referenceOf(token: string, isIP: boolean): { file: string; code: string } | null {
|
|
||||||
const [prefix, ...rest] = token.split(':');
|
|
||||||
const code = (value: string) => value.split('@')[0].toLowerCase();
|
|
||||||
if (prefix === 'geosite') return { file: 'geosite.dat', code: code(rest.join(':')) };
|
|
||||||
if (prefix === 'geoip') return { file: 'geoip.dat', code: code(rest.join(':')) };
|
|
||||||
if (prefix === 'ext') return { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) };
|
|
||||||
return isIP && prefix === 'ext-ip'
|
|
||||||
? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate(tokens: string[], isIP: boolean): GeodataTokenIssue[] {
|
|
||||||
const issues: GeodataTokenIssue[] = [];
|
|
||||||
for (const token of tokens) {
|
|
||||||
const reference = referenceOf(token, isIP);
|
|
||||||
if (!reference) continue;
|
|
||||||
const dataset = DATASETS[reference.file];
|
|
||||||
if (!dataset) {
|
|
||||||
issues.push({ token, reason: 'fileMissing', file: reference.file, code: reference.code });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!dataset.categories.some((category) => category.code === reference.code)) {
|
|
||||||
issues.push({ token, reason: 'categoryMissing', file: reference.file, code: reference.code });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return issues;
|
|
||||||
}
|
|
||||||
|
|
||||||
const routes: GeoRoutes = {
|
|
||||||
'/csrf-token': () => 'storybook-csrf-token',
|
|
||||||
'/panel/api/xray/geodata/files': () => FILES,
|
|
||||||
'/panel/api/xray/geodata/categories': (query) => {
|
|
||||||
const dataset = DATASETS[query.get('file') ?? ''];
|
|
||||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
|
||||||
const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
|
|
||||||
return { total: items.length, items };
|
|
||||||
},
|
|
||||||
'/panel/api/xray/geodata/entries': (query) => {
|
|
||||||
const dataset = DATASETS[query.get('file') ?? ''];
|
|
||||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
|
||||||
const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
|
|
||||||
entry.value.toLowerCase().includes(needle),
|
|
||||||
);
|
|
||||||
const offset = Number(query.get('offset') ?? 0);
|
|
||||||
const limit = Number(query.get('limit') ?? 100);
|
|
||||||
return { total: matched.length, items: matched.slice(offset, offset + limit) };
|
|
||||||
},
|
|
||||||
'/panel/api/xray/geodata/validate': (_query, form) =>
|
|
||||||
validate(parseTokens(form.get('tokens') ?? ''), form.get('kind') === 'ip'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const withGeodata: Decorator = function GeodataBackend(Story) {
|
|
||||||
return (
|
|
||||||
<GeoApi routes={routes}>
|
|
||||||
<Story />
|
|
||||||
</GeoApi>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) {
|
|
||||||
const [current, setCurrent] = useState(value);
|
|
||||||
const [synced, setSynced] = useState(value);
|
|
||||||
if (synced !== value) {
|
|
||||||
setSynced(value);
|
|
||||||
setCurrent(value);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Space orientation="vertical" size={4} style={{ width: 460 }}>
|
|
||||||
<label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
|
|
||||||
<GeoTokenInput {...rest} id={id} value={current} onChange={setCurrent} />
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Geodata/GeoTokenInput',
|
|
||||||
component: GeoTokenInput,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'padded',
|
|
||||||
a11y: {
|
|
||||||
config: {
|
|
||||||
rules: [{ id: 'color-contrast', enabled: false }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component:
|
|
||||||
'Routing rule field for the xray rule editor: a comma separated list of domains/CIDRs and `geosite:` / `geoip:` tokens, with a database button in the addon that opens the geo category browser. Typed tokens are validated against the databases on disk after a short pause, and anything the running core would not resolve is called out under the field. The stories answer `/panel/api/xray/geodata/*` from an in-memory fixture, so validation and the browser both work without a panel backend.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
decorators: [withGeodata],
|
|
||||||
args: { kind: 'domain' },
|
|
||||||
argTypes: {
|
|
||||||
value: { description: 'Comma separated rule string held by the parent form.' },
|
|
||||||
onChange: {
|
|
||||||
description: 'Called with the full rule string on every edit and on Apply from the browser.',
|
|
||||||
},
|
|
||||||
onBlur: {
|
|
||||||
description: 'Forwarded to the input; used by React Hook Form to mark the field touched.',
|
|
||||||
},
|
|
||||||
kind: {
|
|
||||||
description:
|
|
||||||
'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.',
|
|
||||||
control: 'inline-radio',
|
|
||||||
options: ['domain', 'ip'],
|
|
||||||
},
|
|
||||||
placeholder: { description: 'Placeholder shown while the field is empty.' },
|
|
||||||
id: { description: 'Input id, linked to the label rendered by the surrounding form field.' },
|
|
||||||
},
|
|
||||||
render: (args) => <ControlledTokenInput {...args} />,
|
|
||||||
} satisfies Meta<typeof GeoTokenInput>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
export const Empty: Story = {
|
|
||||||
args: { kind: 'domain', value: '', placeholder: 'geosite:google, example.com' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DomainTokens: Story = {
|
|
||||||
args: { kind: 'domain', value: 'geosite:google, google.com' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IpTokens: Story = {
|
|
||||||
args: { kind: 'ip', value: 'geoip:private' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UnknownCategory: Story = {
|
|
||||||
args: { kind: 'domain', value: 'geosite:blabla, geosite:google' },
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await expect(
|
|
||||||
await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 }),
|
|
||||||
).toBeVisible();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import type { Ref } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button, Input, Space, Tooltip, Typography } from 'antd';
|
|
||||||
import type { InputRef } from 'antd';
|
|
||||||
import { DatabaseOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { useValidateGeoTokens, type GeoTokenKind } from '@/api/queries/useGeodata';
|
|
||||||
import { parseTokens } from '@/lib/xray/geoTokens';
|
|
||||||
import type { GeodataTokenIssue, GeoKind } from '@/generated/types';
|
|
||||||
|
|
||||||
import GeoBrowserModal from './GeoBrowserModal';
|
|
||||||
|
|
||||||
const VALIDATION_DELAY = 600;
|
|
||||||
|
|
||||||
// Each reason needs its own wording: a missing database is fixed under Geodata,
|
|
||||||
// a missing category by picking another one, and a bad token by editing it.
|
|
||||||
const REASON_KEYS: Record<string, string> = {
|
|
||||||
fileMissing: 'pages.xray.geoBrowser.missingDatabase',
|
|
||||||
categoryMissing: 'pages.xray.geoBrowser.unknownCategories',
|
|
||||||
attributeMissing: 'pages.xray.geoBrowser.unknownAttribute',
|
|
||||||
syntax: 'pages.xray.geoBrowser.invalidToken',
|
|
||||||
wrongKind: 'pages.xray.geoBrowser.wrongKind',
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface GeoTokenInputProps {
|
|
||||||
value?: string;
|
|
||||||
onChange?: (value: string) => void;
|
|
||||||
onBlur?: () => void;
|
|
||||||
kind: GeoTokenKind;
|
|
||||||
placeholder?: string;
|
|
||||||
id?: string;
|
|
||||||
ref?: Ref<InputRef>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GeoTokenInput({
|
|
||||||
value = '',
|
|
||||||
onChange,
|
|
||||||
onBlur,
|
|
||||||
kind,
|
|
||||||
placeholder,
|
|
||||||
id,
|
|
||||||
ref,
|
|
||||||
}: GeoTokenInputProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [browsing, setBrowsing] = useState(false);
|
|
||||||
const [issues, setIssues] = useState<GeodataTokenIssue[]>([]);
|
|
||||||
const [checkFailed, setCheckFailed] = useState(false);
|
|
||||||
const validate = useValidateGeoTokens();
|
|
||||||
const { mutateAsync } = validate;
|
|
||||||
|
|
||||||
// An empty field has nothing to validate, so it clears during render rather
|
|
||||||
// than waiting a commit for the effect to catch up.
|
|
||||||
const isEmpty = parseTokens(value).length === 0;
|
|
||||||
const [wasEmpty, setWasEmpty] = useState(isEmpty);
|
|
||||||
if (isEmpty !== wasEmpty) {
|
|
||||||
setWasEmpty(isEmpty);
|
|
||||||
if (isEmpty) {
|
|
||||||
setIssues([]);
|
|
||||||
setCheckFailed(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const tokens = parseTokens(value);
|
|
||||||
if (tokens.length === 0) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
mutateAsync({ tokens, kind })
|
|
||||||
.then((found) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setIssues(found);
|
|
||||||
setCheckFailed(false);
|
|
||||||
})
|
|
||||||
// A rejected check says nothing about the tokens, so the warnings are
|
|
||||||
// dropped but replaced by a notice — silence here reads as "all valid".
|
|
||||||
.catch(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setIssues([]);
|
|
||||||
setCheckFailed(true);
|
|
||||||
});
|
|
||||||
}, VALIDATION_DELAY);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [value, kind, mutateAsync]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Space.Compact block>
|
|
||||||
<Input
|
|
||||||
ref={ref}
|
|
||||||
id={id}
|
|
||||||
value={value}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onChange={(event) => onChange?.(event.target.value)}
|
|
||||||
onBlur={onBlur}
|
|
||||||
/>
|
|
||||||
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
|
|
||||||
<Button
|
|
||||||
icon={<DatabaseOutlined />}
|
|
||||||
aria-label={t('pages.xray.geoBrowser.openTooltip')}
|
|
||||||
onClick={() => setBrowsing(true)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Space.Compact>
|
|
||||||
{groupByReason(issues).map(([reason, tokens]) => (
|
|
||||||
<Typography.Text key={reason} type="warning" className="geo-unknown-hint">
|
|
||||||
{t(REASON_KEYS[reason] ?? REASON_KEYS.categoryMissing, { tokens: tokens.join(', ') })}
|
|
||||||
</Typography.Text>
|
|
||||||
))}
|
|
||||||
{checkFailed && (
|
|
||||||
<Typography.Text type="secondary" className="geo-unknown-hint">
|
|
||||||
{t('pages.xray.geoBrowser.checkFailed')}
|
|
||||||
</Typography.Text>
|
|
||||||
)}
|
|
||||||
<GeoBrowserModal
|
|
||||||
open={browsing}
|
|
||||||
kind={(kind === 'ip' ? 'ip' : 'site') as GeoKind}
|
|
||||||
value={value}
|
|
||||||
onApply={(next) => {
|
|
||||||
onChange?.(next);
|
|
||||||
setBrowsing(false);
|
|
||||||
}}
|
|
||||||
onClose={() => setBrowsing(false)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupByReason(issues: GeodataTokenIssue[]): Array<[string, string[]]> {
|
|
||||||
const grouped = new Map<string, string[]>();
|
|
||||||
for (const issue of issues) {
|
|
||||||
const tokens = grouped.get(issue.reason) ?? [];
|
|
||||||
tokens.push(issue.token);
|
|
||||||
grouped.set(issue.reason, tokens);
|
|
||||||
}
|
|
||||||
return [...grouped];
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { default as GeoBrowserModal } from './GeoBrowserModal';
|
|
||||||
export type { GeoBrowserModalProps } from './GeoBrowserModal';
|
|
||||||
export { default as GeoTokenInput } from './GeoTokenInput';
|
|
||||||
export type { GeoTokenInputProps } from './GeoTokenInput';
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
|
|
||||||
import { AllSetting } from '@/models/setting';
|
|
||||||
import { EmailNotifications } from './EmailNotifications';
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'UI/Notifications/EmailNotifications',
|
|
||||||
component: EmailNotifications,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'padded',
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component:
|
|
||||||
'Grid of grouped event checkboxes on the settings page that picks which panel events (outbound/node health, Xray crashes, CPU/RAM thresholds, login attempts) trigger an SMTP email, stored as a comma-separated list in smtpEnabledEvents.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
allSetting: {
|
|
||||||
description:
|
|
||||||
'Panel settings snapshot; smtpEnabledEvents holds the selected event keys and smtpCpu/smtpMemory the alert threshold percentages.',
|
|
||||||
},
|
|
||||||
updateSetting: {
|
|
||||||
description:
|
|
||||||
'Receives a partial settings patch when an event is toggled or a threshold input changes.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof EmailNotifications>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
function StatefulDemo({ initial }: { initial: AllSetting }) {
|
|
||||||
const [settings, setSettings] = useState(initial);
|
|
||||||
return (
|
|
||||||
<EmailNotifications
|
|
||||||
allSetting={settings}
|
|
||||||
updateSetting={(patch) => setSettings((prev) => new AllSetting({ ...prev, ...patch }))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const placeholderArgs = {
|
|
||||||
allSetting: new AllSetting(),
|
|
||||||
updateSetting: () => undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NothingSelected: Story = {
|
|
||||||
args: placeholderArgs,
|
|
||||||
render: () => <StatefulDemo initial={new AllSetting()} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SystemThresholdAlerts: Story = {
|
|
||||||
args: placeholderArgs,
|
|
||||||
render: () => (
|
|
||||||
<StatefulDemo
|
|
||||||
initial={
|
|
||||||
new AllSetting({ smtpEnabledEvents: 'cpu.high,memory.high', smtpCpu: 85, smtpMemory: 90 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InfrastructureOnly: Story = {
|
|
||||||
args: placeholderArgs,
|
|
||||||
render: () => (
|
|
||||||
<StatefulDemo
|
|
||||||
initial={new AllSetting({ smtpEnabledEvents: 'outbound.down,node.down,node.up,xray.crash' })}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AllEventsEnabled: Story = {
|
|
||||||
args: placeholderArgs,
|
|
||||||
render: () => (
|
|
||||||
<StatefulDemo
|
|
||||||
initial={
|
|
||||||
new AllSetting({
|
|
||||||
smtpEnabledEvents:
|
|
||||||
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
|
|
||||||
smtpCpu: 80,
|
|
||||||
smtpMemory: 80,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { InputNumber } from 'antd';
|
|
||||||
import {
|
|
||||||
CloudServerOutlined,
|
|
||||||
ThunderboltOutlined,
|
|
||||||
DesktopOutlined,
|
|
||||||
DashboardOutlined,
|
|
||||||
SafetyOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { AllSetting } from '@/models/setting';
|
|
||||||
import { NotificationLayout } from './NotificationLayout';
|
|
||||||
import { NotificationGroup } from './NotificationGroup';
|
|
||||||
import type { NotificationGroupConfig } from './types';
|
|
||||||
|
|
||||||
const GROUPS: NotificationGroupConfig[] = [
|
|
||||||
{
|
|
||||||
icon: <CloudServerOutlined />,
|
|
||||||
title: 'eventGroupOutbound',
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
key: 'outbound.down',
|
|
||||||
label: 'eventOutboundDown',
|
|
||||||
settingKey: 'outboundDownThreshold',
|
|
||||||
extra: ({ value, onChange, ariaLabel }) => (
|
|
||||||
<InputNumber
|
|
||||||
size="small"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <ThunderboltOutlined />,
|
|
||||||
title: 'eventGroupXray',
|
|
||||||
events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <DesktopOutlined />,
|
|
||||||
title: 'eventGroupNode',
|
|
||||||
events: [
|
|
||||||
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' },
|
|
||||||
{ key: 'node.up', label: 'eventNodeUp', settingKey: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <DashboardOutlined />,
|
|
||||||
title: 'eventGroupSystem',
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
key: 'cpu.high',
|
|
||||||
label: 'eventCPUHigh',
|
|
||||||
settingKey: 'smtpCpu',
|
|
||||||
extra: ({ value, onChange, ariaLabel }) => (
|
|
||||||
<InputNumber
|
|
||||||
size="small"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'memory.high',
|
|
||||||
label: 'eventMemoryHigh',
|
|
||||||
settingKey: 'smtpMemory',
|
|
||||||
extra: ({ value, onChange, ariaLabel }) => (
|
|
||||||
<InputNumber
|
|
||||||
size="small"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <SafetyOutlined />,
|
|
||||||
title: 'eventGroupSecurity',
|
|
||||||
events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
allSetting: AllSetting;
|
|
||||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmailNotifications({ allSetting, updateSetting }: Props) {
|
|
||||||
const events = allSetting.smtpEnabledEvents || '';
|
|
||||||
const selected = events
|
|
||||||
? events
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
function toggle(key: string) {
|
|
||||||
const next = selected.includes(key) ? selected.filter((e) => e !== key) : [...selected, key];
|
|
||||||
updateSetting({ smtpEnabledEvents: next.join(',') });
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAll(keys: string[]) {
|
|
||||||
const allSelected = keys.every((v) => selected.includes(v));
|
|
||||||
const next = allSelected
|
|
||||||
? selected.filter((v) => !keys.includes(v))
|
|
||||||
: [...new Set([...selected, ...keys])];
|
|
||||||
updateSetting({ smtpEnabledEvents: next.join(',') });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NotificationLayout>
|
|
||||||
{GROUPS.map((group, i) => (
|
|
||||||
<NotificationGroup
|
|
||||||
key={i}
|
|
||||||
config={group}
|
|
||||||
selected={selected}
|
|
||||||
onToggle={toggle}
|
|
||||||
onToggleAll={toggleAll}
|
|
||||||
allSetting={allSetting}
|
|
||||||
updateSetting={updateSetting}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</NotificationLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -61,7 +61,7 @@ export const Unchecked: Story = {
|
|||||||
|
|
||||||
export const Checked: Story = {
|
export const Checked: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: 'pages.settings.eventXrayCrash',
|
label: 'pages.settings.eventNodeDown',
|
||||||
checked: true,
|
checked: true,
|
||||||
onToggle: () => undefined,
|
onToggle: () => undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { InputNumber } from 'antd';
|
import { InputNumber } from 'antd';
|
||||||
import { CloudServerOutlined, DashboardOutlined } from '@ant-design/icons';
|
import { DashboardOutlined, DesktopOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { AllSetting } from '@/models/setting';
|
import { AllSetting } from '@/models/setting';
|
||||||
import { NotificationGroup } from './NotificationGroup';
|
import { NotificationGroup } from './NotificationGroup';
|
||||||
@@ -46,12 +46,12 @@ const systemGroup: NotificationGroupConfig = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const outboundGroup: NotificationGroupConfig = {
|
const nodeGroup: NotificationGroupConfig = {
|
||||||
icon: <CloudServerOutlined />,
|
icon: <DesktopOutlined />,
|
||||||
title: 'eventGroupOutbound',
|
title: 'eventGroupNode',
|
||||||
events: [
|
events: [
|
||||||
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
|
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' },
|
||||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
{ key: 'node.up', label: 'eventNodeUp', settingKey: '' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ const meta = {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Card for one notification event group (outbound, Xray, node, system, security) with a per-group select-all checkbox, a selected-count tag, and optional per-event threshold inputs. Composed by the Telegram and email notification tabs on the settings page.',
|
'Card for one notification event group (node, system, security) with a per-group select-all checkbox, a selected-count tag, and optional per-event threshold inputs. Composed by the Telegram and email notification tabs on the settings page.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -138,7 +138,7 @@ export const PartiallySelected: Story = {
|
|||||||
|
|
||||||
export const NoneSelected: Story = {
|
export const NoneSelected: Story = {
|
||||||
args: {
|
args: {
|
||||||
config: outboundGroup,
|
config: nodeGroup,
|
||||||
selected: [],
|
selected: [],
|
||||||
onToggle: () => undefined,
|
onToggle: () => undefined,
|
||||||
onToggleAll: () => undefined,
|
onToggleAll: () => undefined,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const TypicalMonitoring: Story = {
|
|||||||
new AllSetting({
|
new AllSetting({
|
||||||
tgBotEnable: true,
|
tgBotEnable: true,
|
||||||
tgBotChatId: '123456789',
|
tgBotChatId: '123456789',
|
||||||
tgEnabledEvents: 'xray.crash,node.down,cpu.high,memory.high,login.attempt',
|
tgEnabledEvents: 'node.down,cpu.high,memory.high,login.attempt',
|
||||||
tgCpu: 85,
|
tgCpu: 85,
|
||||||
tgMemory: 90,
|
tgMemory: 90,
|
||||||
})
|
})
|
||||||
@@ -77,8 +77,7 @@ export const EverythingEnabled: Story = {
|
|||||||
initial={
|
initial={
|
||||||
new AllSetting({
|
new AllSetting({
|
||||||
tgBotEnable: true,
|
tgBotEnable: true,
|
||||||
tgEnabledEvents:
|
tgEnabledEvents: 'node.down,node.up,cpu.high,memory.high,login.attempt',
|
||||||
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
|
|
||||||
tgCpu: 70,
|
tgCpu: 70,
|
||||||
tgMemory: 75,
|
tgMemory: 75,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,45 +1,11 @@
|
|||||||
import { InputNumber } from 'antd';
|
import { InputNumber } from 'antd';
|
||||||
import {
|
import { DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||||
CloudServerOutlined,
|
|
||||||
ThunderboltOutlined,
|
|
||||||
DesktopOutlined,
|
|
||||||
DashboardOutlined,
|
|
||||||
SafetyOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { AllSetting } from '@/models/setting';
|
import type { AllSetting } from '@/models/setting';
|
||||||
import { NotificationLayout } from './NotificationLayout';
|
import { NotificationLayout } from './NotificationLayout';
|
||||||
import { NotificationGroup } from './NotificationGroup';
|
import { NotificationGroup } from './NotificationGroup';
|
||||||
import type { NotificationGroupConfig } from './types';
|
import type { NotificationGroupConfig } from './types';
|
||||||
|
|
||||||
const GROUPS: NotificationGroupConfig[] = [
|
const GROUPS: NotificationGroupConfig[] = [
|
||||||
{
|
|
||||||
icon: <CloudServerOutlined />,
|
|
||||||
title: 'eventGroupOutbound',
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
key: 'outbound.down',
|
|
||||||
label: 'eventOutboundDown',
|
|
||||||
settingKey: 'outboundDownThreshold',
|
|
||||||
extra: ({ value, onChange, ariaLabel }) => (
|
|
||||||
<InputNumber
|
|
||||||
size="small"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <ThunderboltOutlined />,
|
|
||||||
title: 'eventGroupXray',
|
|
||||||
events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: <DesktopOutlined />,
|
icon: <DesktopOutlined />,
|
||||||
title: 'eventGroupNode',
|
title: 'eventGroupNode',
|
||||||
|
|||||||
Vendored
+1
-3
@@ -12,12 +12,10 @@ interface SubPageData {
|
|||||||
expire?: string | number;
|
expire?: string | number;
|
||||||
lastOnline?: string | number;
|
lastOnline?: string | number;
|
||||||
subUrl?: string;
|
subUrl?: string;
|
||||||
subJsonUrl?: string;
|
|
||||||
subClashUrl?: string;
|
|
||||||
subTitle?: string;
|
subTitle?: string;
|
||||||
links?: string[];
|
links?: string[];
|
||||||
emails?: string[];
|
emails?: string[];
|
||||||
datepicker?: 'gregorian' | 'jalalian';
|
datepicker?: 'gregorian';
|
||||||
announce?: string;
|
announce?: string;
|
||||||
downloadByte?: string | number;
|
downloadByte?: string | number;
|
||||||
uploadByte?: string | number;
|
uploadByte?: string | number;
|
||||||
|
|||||||
@@ -3,83 +3,23 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"AllSetting": {
|
"AllSetting": {
|
||||||
"datepicker": "",
|
"datepicker": "",
|
||||||
"expireDiff": 0,
|
"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,
|
"pageSize": 0,
|
||||||
"panelOutbound": "",
|
|
||||||
"remarkTemplate": "",
|
"remarkTemplate": "",
|
||||||
"restartXrayOnClientDisable": false,
|
"restartXrayOnClientDisable": false,
|
||||||
"sessionMaxAge": 1,
|
"sessionMaxAge": 1,
|
||||||
"smtpCpu": 0,
|
|
||||||
"smtpEnable": false,
|
|
||||||
"smtpEnabledEvents": "",
|
|
||||||
"smtpEncryptionType": "",
|
|
||||||
"smtpFrom": "",
|
|
||||||
"smtpFromName": "",
|
|
||||||
"smtpHost": "",
|
|
||||||
"smtpMemory": 0,
|
|
||||||
"smtpPassword": "",
|
|
||||||
"smtpPort": 1,
|
|
||||||
"smtpTo": "",
|
|
||||||
"smtpUsername": "",
|
|
||||||
"subAnnounce": "",
|
"subAnnounce": "",
|
||||||
"subCertFile": "",
|
"subCertFile": "",
|
||||||
"subClashAutoDetect": false,
|
|
||||||
"subClashEnable": false,
|
|
||||||
"subClashEnableRouting": false,
|
|
||||||
"subClashPath": "",
|
|
||||||
"subClashRules": "",
|
|
||||||
"subClashURI": "",
|
|
||||||
"subClashUserAgentRegex": "",
|
|
||||||
"subDomain": "",
|
"subDomain": "",
|
||||||
"subEnable": false,
|
"subEnable": false,
|
||||||
"subEnableRouting": false,
|
|
||||||
"subEncrypt": false,
|
"subEncrypt": false,
|
||||||
"subHideSettings": false,
|
"subHideSettings": false,
|
||||||
"subIncyEnableRouting": false,
|
|
||||||
"subIncyRoutingRules": "",
|
|
||||||
"subJsonAlwaysArray": false,
|
|
||||||
"subJsonAutoDetect": false,
|
|
||||||
"subJsonEnable": false,
|
|
||||||
"subJsonFinalMask": "",
|
|
||||||
"subJsonMux": "",
|
|
||||||
"subJsonObservatory": "",
|
|
||||||
"subJsonPath": "",
|
|
||||||
"subJsonRules": "",
|
|
||||||
"subJsonURI": "",
|
|
||||||
"subJsonUserAgentRegex": "",
|
|
||||||
"subKeyFile": "",
|
"subKeyFile": "",
|
||||||
"subListen": "",
|
"subListen": "",
|
||||||
"subPath": "",
|
"subPath": "",
|
||||||
"subPort": 1,
|
"subPort": 0,
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
|
||||||
"subShowIdentityOnAllLinks": false,
|
"subShowIdentityOnAllLinks": false,
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
"subThemeDir": "",
|
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
"subURI": "",
|
"subURI": "",
|
||||||
"subUpdates": 0,
|
"subUpdates": 0,
|
||||||
@@ -96,9 +36,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"tgRunTime": "",
|
"tgRunTime": "",
|
||||||
"timeLocation": "",
|
"timeLocation": "",
|
||||||
"trafficDiff": 0,
|
"trafficDiff": 0,
|
||||||
|
"trafficStatsSwitches": "",
|
||||||
"trustedProxyCIDRs": "",
|
"trustedProxyCIDRs": "",
|
||||||
"twoFactorEnable": false,
|
|
||||||
"twoFactorToken": "",
|
|
||||||
"warpUpdateInterval": 0,
|
"warpUpdateInterval": 0,
|
||||||
"webBasePath": "",
|
"webBasePath": "",
|
||||||
"webCertFile": "",
|
"webCertFile": "",
|
||||||
@@ -110,90 +49,27 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"AllSettingView": {
|
"AllSettingView": {
|
||||||
"datepicker": "",
|
"datepicker": "",
|
||||||
"expireDiff": 0,
|
"expireDiff": 0,
|
||||||
"externalTrafficInformEnable": false,
|
|
||||||
"externalTrafficInformURI": "",
|
|
||||||
"hasApiToken": false,
|
"hasApiToken": false,
|
||||||
"hasLdapPassword": false,
|
|
||||||
"hasNordSecret": false,
|
"hasNordSecret": false,
|
||||||
"hasSmtpPassword": false,
|
|
||||||
"hasTgBotToken": false,
|
"hasTgBotToken": false,
|
||||||
"hasTwoFactorToken": false,
|
|
||||||
"hasWarpSecret": 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,
|
"pageSize": 0,
|
||||||
"panelOutbound": "",
|
|
||||||
"remarkTemplate": "",
|
"remarkTemplate": "",
|
||||||
"restartXrayOnClientDisable": false,
|
"restartXrayOnClientDisable": false,
|
||||||
"sessionMaxAge": 1,
|
"sessionMaxAge": 1,
|
||||||
"smtpCpu": 0,
|
|
||||||
"smtpEnable": false,
|
|
||||||
"smtpEnabledEvents": "",
|
|
||||||
"smtpEncryptionType": "",
|
|
||||||
"smtpFrom": "",
|
|
||||||
"smtpFromName": "",
|
|
||||||
"smtpHost": "",
|
|
||||||
"smtpMemory": 0,
|
|
||||||
"smtpPassword": "",
|
|
||||||
"smtpPort": 1,
|
|
||||||
"smtpTo": "",
|
|
||||||
"smtpUsername": "",
|
|
||||||
"subAnnounce": "",
|
"subAnnounce": "",
|
||||||
"subCertFile": "",
|
"subCertFile": "",
|
||||||
"subClashAutoDetect": false,
|
|
||||||
"subClashEnable": false,
|
|
||||||
"subClashEnableRouting": false,
|
|
||||||
"subClashPath": "",
|
|
||||||
"subClashRules": "",
|
|
||||||
"subClashURI": "",
|
|
||||||
"subClashUserAgentRegex": "",
|
|
||||||
"subDomain": "",
|
"subDomain": "",
|
||||||
"subEnable": false,
|
"subEnable": false,
|
||||||
"subEnableRouting": false,
|
|
||||||
"subEncrypt": false,
|
"subEncrypt": false,
|
||||||
"subHideSettings": false,
|
"subHideSettings": false,
|
||||||
"subIncyEnableRouting": false,
|
|
||||||
"subIncyRoutingRules": "",
|
|
||||||
"subJsonAlwaysArray": false,
|
|
||||||
"subJsonAutoDetect": false,
|
|
||||||
"subJsonEnable": false,
|
|
||||||
"subJsonFinalMask": "",
|
|
||||||
"subJsonMux": "",
|
|
||||||
"subJsonObservatory": "",
|
|
||||||
"subJsonPath": "",
|
|
||||||
"subJsonRules": "",
|
|
||||||
"subJsonURI": "",
|
|
||||||
"subJsonUserAgentRegex": "",
|
|
||||||
"subKeyFile": "",
|
"subKeyFile": "",
|
||||||
"subListen": "",
|
"subListen": "",
|
||||||
"subPath": "",
|
"subPath": "",
|
||||||
"subPort": 1,
|
"subPort": 0,
|
||||||
"subProfileUrl": "",
|
"subProfileUrl": "",
|
||||||
"subRoutingRules": "",
|
|
||||||
"subShowIdentityOnAllLinks": false,
|
"subShowIdentityOnAllLinks": false,
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
"subThemeDir": "",
|
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
"subURI": "",
|
"subURI": "",
|
||||||
"subUpdates": 0,
|
"subUpdates": 0,
|
||||||
@@ -210,9 +86,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"tgRunTime": "",
|
"tgRunTime": "",
|
||||||
"timeLocation": "",
|
"timeLocation": "",
|
||||||
"trafficDiff": 0,
|
"trafficDiff": 0,
|
||||||
|
"trafficStatsSwitches": "",
|
||||||
"trustedProxyCIDRs": "",
|
"trustedProxyCIDRs": "",
|
||||||
"twoFactorEnable": false,
|
|
||||||
"twoFactorToken": "",
|
|
||||||
"warpUpdateInterval": 0,
|
"warpUpdateInterval": 0,
|
||||||
"webBasePath": "",
|
"webBasePath": "",
|
||||||
"webCertFile": "",
|
"webCertFile": "",
|
||||||
@@ -221,26 +96,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"webListen": "",
|
"webListen": "",
|
||||||
"webPort": 1
|
"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": {
|
"ApiToken": {
|
||||||
"createdAt": 0,
|
"createdAt": 0,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -401,12 +256,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"name": "geosite.dat",
|
"name": "geosite.dat",
|
||||||
"size": 1467392
|
"size": 1467392
|
||||||
},
|
},
|
||||||
"GeodataTokenIssue": {
|
|
||||||
"code": "blabla",
|
|
||||||
"file": "geosite.dat",
|
|
||||||
"reason": "categoryMissing",
|
|
||||||
"token": "geosite:blabla"
|
|
||||||
},
|
|
||||||
"HistoryOfSeeders": {
|
"HistoryOfSeeders": {
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"seederName": ""
|
"seederName": ""
|
||||||
@@ -530,6 +379,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"enable": true,
|
"enable": true,
|
||||||
"expiryTime": 0,
|
"expiryTime": 0,
|
||||||
"fallbackParent": null,
|
"fallbackParent": null,
|
||||||
|
"forwarding": "",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"lastTrafficResetTime": 0,
|
"lastTrafficResetTime": 0,
|
||||||
"listen": "",
|
"listen": "",
|
||||||
@@ -648,16 +498,23 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
""
|
""
|
||||||
],
|
],
|
||||||
"name": "",
|
"name": "",
|
||||||
|
"nodeType": "traditional",
|
||||||
"outboundTag": "",
|
"outboundTag": "",
|
||||||
"pinnedCertSha256": "",
|
"pinnedCertSha256": "",
|
||||||
"port": 1,
|
"port": 0,
|
||||||
"remark": "",
|
"remark": "",
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"tlsVerifyMode": "verify"
|
"tlsVerifyMode": "verify"
|
||||||
},
|
},
|
||||||
"NodeView": {
|
"NodeView": {
|
||||||
"activeCount": 20,
|
"activeCount": 20,
|
||||||
|
"activeEmails": [
|
||||||
|
"a@example.com"
|
||||||
|
],
|
||||||
"address": "node.example.com",
|
"address": "node.example.com",
|
||||||
|
"agentUpdateError": "",
|
||||||
|
"agentUpdateState": "failed",
|
||||||
|
"agentUpdateTarget": "A1.0.1",
|
||||||
"allowPrivateAddress": false,
|
"allowPrivateAddress": false,
|
||||||
"basePath": "/",
|
"basePath": "/",
|
||||||
"clientCount": 25,
|
"clientCount": 25,
|
||||||
@@ -666,7 +523,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"cpuPct": 12.5,
|
"cpuPct": 12.5,
|
||||||
"createdAt": 1700000000,
|
"createdAt": 1700000000,
|
||||||
"depletedCount": 1,
|
"depletedCount": 1,
|
||||||
|
"depletedEmails": [
|
||||||
|
"c@example.com"
|
||||||
|
],
|
||||||
"disabledCount": 2,
|
"disabledCount": 2,
|
||||||
|
"disabledEmails": [
|
||||||
|
"b@example.com"
|
||||||
|
],
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"guid": "node-guid",
|
"guid": "node-guid",
|
||||||
"hasApiToken": true,
|
"hasApiToken": true,
|
||||||
@@ -676,6 +539,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"inboundTags": [
|
"inboundTags": [
|
||||||
"in-443-tcp"
|
"in-443-tcp"
|
||||||
],
|
],
|
||||||
|
"ipv4": "203.0.113.7",
|
||||||
|
"ipv6": "2001:db8::7",
|
||||||
"lastError": "",
|
"lastError": "",
|
||||||
"lastHeartbeat": 1700000000,
|
"lastHeartbeat": 1700000000,
|
||||||
"latencyMs": 42,
|
"latencyMs": 42,
|
||||||
@@ -683,7 +548,11 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"name": "edge-1",
|
"name": "edge-1",
|
||||||
"netDown": 1048576,
|
"netDown": 1048576,
|
||||||
"netUp": 2097152,
|
"netUp": 2097152,
|
||||||
|
"nodeType": "traditional",
|
||||||
"onlineCount": 5,
|
"onlineCount": 5,
|
||||||
|
"onlineEmails": [
|
||||||
|
"a@example.com"
|
||||||
|
],
|
||||||
"outboundTag": "direct",
|
"outboundTag": "direct",
|
||||||
"panelVersion": "v3.x.x",
|
"panelVersion": "v3.x.x",
|
||||||
"parentGuid": "",
|
"parentGuid": "",
|
||||||
@@ -713,18 +582,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"runId": "1735689600123456789",
|
"runId": "1735689600123456789",
|
||||||
"state": "success"
|
"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": {
|
"ProbeResultUI": {
|
||||||
"cpuPct": 12.5,
|
"cpuPct": 12.5,
|
||||||
"error": "",
|
"error": "",
|
||||||
@@ -805,19 +662,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"key": "",
|
"key": "",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
"SubBalancer": {
|
|
||||||
"createdAt": 1710000000000,
|
|
||||||
"enabled": true,
|
|
||||||
"id": 1,
|
|
||||||
"inboundIds": [
|
|
||||||
1,
|
|
||||||
3
|
|
||||||
],
|
|
||||||
"remark": "auto-fastest",
|
|
||||||
"sortOrder": 1,
|
|
||||||
"strategy": "random",
|
|
||||||
"updatedAt": 1710000000000
|
|
||||||
},
|
|
||||||
"User": {
|
"User": {
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"password": "",
|
"password": "",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+15
-164
@@ -4,6 +4,7 @@ export type OnlineAPISupport = number;
|
|||||||
export type ProcessState = string;
|
export type ProcessState = string;
|
||||||
export type Protocol = string;
|
export type Protocol = string;
|
||||||
export type SubLinkProvider = unknown;
|
export type SubLinkProvider = unknown;
|
||||||
|
export type rawBytes = number[];
|
||||||
export type staticEgressResolver = string;
|
export type staticEgressResolver = string;
|
||||||
export type trafficLocalApplyAction = number;
|
export type trafficLocalApplyAction = number;
|
||||||
export type transportBits = number;
|
export type transportBits = number;
|
||||||
@@ -11,83 +12,23 @@ export type transportBits = number;
|
|||||||
export interface AllSetting {
|
export interface AllSetting {
|
||||||
datepicker: string;
|
datepicker: string;
|
||||||
expireDiff: number;
|
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;
|
pageSize: number;
|
||||||
panelOutbound: string;
|
|
||||||
remarkTemplate: string;
|
remarkTemplate: string;
|
||||||
restartXrayOnClientDisable: boolean;
|
restartXrayOnClientDisable: boolean;
|
||||||
sessionMaxAge: number;
|
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;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
subClashAutoDetect: boolean;
|
|
||||||
subClashEnable: boolean;
|
|
||||||
subClashEnableRouting: boolean;
|
|
||||||
subClashPath: string;
|
|
||||||
subClashRules: string;
|
|
||||||
subClashURI: string;
|
|
||||||
subClashUserAgentRegex: string;
|
|
||||||
subDomain: string;
|
subDomain: string;
|
||||||
subEnable: boolean;
|
subEnable: boolean;
|
||||||
subEnableRouting: boolean;
|
|
||||||
subEncrypt: boolean;
|
subEncrypt: boolean;
|
||||||
subHideSettings: boolean;
|
subHideSettings: boolean;
|
||||||
subIncyEnableRouting: boolean;
|
|
||||||
subIncyRoutingRules: string;
|
|
||||||
subJsonAlwaysArray: boolean;
|
|
||||||
subJsonAutoDetect: boolean;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
subJsonFinalMask: string;
|
|
||||||
subJsonMux: string;
|
|
||||||
subJsonObservatory: string;
|
|
||||||
subJsonPath: string;
|
|
||||||
subJsonRules: string;
|
|
||||||
subJsonURI: string;
|
|
||||||
subJsonUserAgentRegex: string;
|
|
||||||
subKeyFile: string;
|
subKeyFile: string;
|
||||||
subListen: string;
|
subListen: string;
|
||||||
subPath: string;
|
subPath: string;
|
||||||
subPort: number;
|
subPort: number;
|
||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subRoutingRules: string;
|
|
||||||
subShowIdentityOnAllLinks: boolean;
|
subShowIdentityOnAllLinks: boolean;
|
||||||
subSupportUrl: string;
|
subSupportUrl: string;
|
||||||
subThemeDir: string;
|
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subUpdates: number;
|
subUpdates: number;
|
||||||
@@ -104,9 +45,8 @@ export interface AllSetting {
|
|||||||
tgRunTime: string;
|
tgRunTime: string;
|
||||||
timeLocation: string;
|
timeLocation: string;
|
||||||
trafficDiff: number;
|
trafficDiff: number;
|
||||||
|
trafficStatsSwitches: string;
|
||||||
trustedProxyCIDRs: string;
|
trustedProxyCIDRs: string;
|
||||||
twoFactorEnable: boolean;
|
|
||||||
twoFactorToken: string;
|
|
||||||
warpUpdateInterval: number;
|
warpUpdateInterval: number;
|
||||||
webBasePath: string;
|
webBasePath: string;
|
||||||
webCertFile: string;
|
webCertFile: string;
|
||||||
@@ -119,90 +59,27 @@ export interface AllSetting {
|
|||||||
export interface AllSettingView {
|
export interface AllSettingView {
|
||||||
datepicker: string;
|
datepicker: string;
|
||||||
expireDiff: number;
|
expireDiff: number;
|
||||||
externalTrafficInformEnable: boolean;
|
|
||||||
externalTrafficInformURI: string;
|
|
||||||
hasApiToken: boolean;
|
hasApiToken: boolean;
|
||||||
hasLdapPassword: boolean;
|
|
||||||
hasNordSecret: boolean;
|
hasNordSecret: boolean;
|
||||||
hasSmtpPassword: boolean;
|
|
||||||
hasTgBotToken: boolean;
|
hasTgBotToken: boolean;
|
||||||
hasTwoFactorToken: boolean;
|
|
||||||
hasWarpSecret: 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;
|
pageSize: number;
|
||||||
panelOutbound: string;
|
|
||||||
remarkTemplate: string;
|
remarkTemplate: string;
|
||||||
restartXrayOnClientDisable: boolean;
|
restartXrayOnClientDisable: boolean;
|
||||||
sessionMaxAge: number;
|
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;
|
subAnnounce: string;
|
||||||
subCertFile: string;
|
subCertFile: string;
|
||||||
subClashAutoDetect: boolean;
|
|
||||||
subClashEnable: boolean;
|
|
||||||
subClashEnableRouting: boolean;
|
|
||||||
subClashPath: string;
|
|
||||||
subClashRules: string;
|
|
||||||
subClashURI: string;
|
|
||||||
subClashUserAgentRegex: string;
|
|
||||||
subDomain: string;
|
subDomain: string;
|
||||||
subEnable: boolean;
|
subEnable: boolean;
|
||||||
subEnableRouting: boolean;
|
|
||||||
subEncrypt: boolean;
|
subEncrypt: boolean;
|
||||||
subHideSettings: boolean;
|
subHideSettings: boolean;
|
||||||
subIncyEnableRouting: boolean;
|
|
||||||
subIncyRoutingRules: string;
|
|
||||||
subJsonAlwaysArray: boolean;
|
|
||||||
subJsonAutoDetect: boolean;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
subJsonFinalMask: string;
|
|
||||||
subJsonMux: string;
|
|
||||||
subJsonObservatory: string;
|
|
||||||
subJsonPath: string;
|
|
||||||
subJsonRules: string;
|
|
||||||
subJsonURI: string;
|
|
||||||
subJsonUserAgentRegex: string;
|
|
||||||
subKeyFile: string;
|
subKeyFile: string;
|
||||||
subListen: string;
|
subListen: string;
|
||||||
subPath: string;
|
subPath: string;
|
||||||
subPort: number;
|
subPort: number;
|
||||||
subProfileUrl: string;
|
subProfileUrl: string;
|
||||||
subRoutingRules: string;
|
|
||||||
subShowIdentityOnAllLinks: boolean;
|
subShowIdentityOnAllLinks: boolean;
|
||||||
subSupportUrl: string;
|
subSupportUrl: string;
|
||||||
subThemeDir: string;
|
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subUpdates: number;
|
subUpdates: number;
|
||||||
@@ -219,9 +96,8 @@ export interface AllSettingView {
|
|||||||
tgRunTime: string;
|
tgRunTime: string;
|
||||||
timeLocation: string;
|
timeLocation: string;
|
||||||
trafficDiff: number;
|
trafficDiff: number;
|
||||||
|
trafficStatsSwitches: string;
|
||||||
trustedProxyCIDRs: string;
|
trustedProxyCIDRs: string;
|
||||||
twoFactorEnable: boolean;
|
|
||||||
twoFactorToken: string;
|
|
||||||
warpUpdateInterval: number;
|
warpUpdateInterval: number;
|
||||||
webBasePath: string;
|
webBasePath: string;
|
||||||
webCertFile: string;
|
webCertFile: string;
|
||||||
@@ -231,12 +107,6 @@ export interface AllSettingView {
|
|||||||
webPort: number;
|
webPort: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AmneziaWGLogs {
|
|
||||||
events: string[];
|
|
||||||
peers: PeerActivity[];
|
|
||||||
running: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiToken {
|
export interface ApiToken {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -391,13 +261,6 @@ export interface GeoFile {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeodataTokenIssue {
|
|
||||||
code?: string;
|
|
||||||
file?: string;
|
|
||||||
reason: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryOfSeeders {
|
export interface HistoryOfSeeders {
|
||||||
id: number;
|
id: number;
|
||||||
seederName: string;
|
seederName: string;
|
||||||
@@ -481,6 +344,7 @@ export interface Inbound {
|
|||||||
enable: boolean;
|
enable: boolean;
|
||||||
expiryTime: number;
|
expiryTime: number;
|
||||||
fallbackParent?: FallbackParentInfo | null;
|
fallbackParent?: FallbackParentInfo | null;
|
||||||
|
forwarding: string;
|
||||||
id: number;
|
id: number;
|
||||||
lastTrafficResetTime: number;
|
lastTrafficResetTime: number;
|
||||||
listen: string;
|
listen: string;
|
||||||
@@ -601,6 +465,7 @@ export interface NodeMutationRequest {
|
|||||||
inboundSyncMode: string;
|
inboundSyncMode: string;
|
||||||
inboundTags: string[];
|
inboundTags: string[];
|
||||||
name: string;
|
name: string;
|
||||||
|
nodeType?: string;
|
||||||
outboundTag: string;
|
outboundTag: string;
|
||||||
pinnedCertSha256: string;
|
pinnedCertSha256: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -611,7 +476,11 @@ export interface NodeMutationRequest {
|
|||||||
|
|
||||||
export interface NodeView {
|
export interface NodeView {
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
|
activeEmails?: string[];
|
||||||
address: string;
|
address: string;
|
||||||
|
agentUpdateError?: string;
|
||||||
|
agentUpdateState?: string;
|
||||||
|
agentUpdateTarget?: string;
|
||||||
allowPrivateAddress: boolean;
|
allowPrivateAddress: boolean;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
clientCount: number;
|
clientCount: number;
|
||||||
@@ -620,7 +489,9 @@ export interface NodeView {
|
|||||||
cpuPct: number;
|
cpuPct: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
depletedCount: number;
|
depletedCount: number;
|
||||||
|
depletedEmails?: string[];
|
||||||
disabledCount: number;
|
disabledCount: number;
|
||||||
|
disabledEmails?: string[];
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
guid: string;
|
guid: string;
|
||||||
hasApiToken: boolean;
|
hasApiToken: boolean;
|
||||||
@@ -628,6 +499,8 @@ export interface NodeView {
|
|||||||
inboundCount: number;
|
inboundCount: number;
|
||||||
inboundSyncMode: string;
|
inboundSyncMode: string;
|
||||||
inboundTags: string[];
|
inboundTags: string[];
|
||||||
|
ipv4?: string;
|
||||||
|
ipv6?: string;
|
||||||
lastError: string;
|
lastError: string;
|
||||||
lastHeartbeat: number;
|
lastHeartbeat: number;
|
||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
@@ -635,7 +508,9 @@ export interface NodeView {
|
|||||||
name: string;
|
name: string;
|
||||||
netDown: number;
|
netDown: number;
|
||||||
netUp: number;
|
netUp: number;
|
||||||
|
nodeType?: string;
|
||||||
onlineCount: number;
|
onlineCount: number;
|
||||||
|
onlineEmails?: string[];
|
||||||
outboundTag: string;
|
outboundTag: string;
|
||||||
panelVersion: string;
|
panelVersion: string;
|
||||||
parentGuid?: string;
|
parentGuid?: string;
|
||||||
@@ -668,19 +543,6 @@ export interface PanelUpdateStatus {
|
|||||||
state: string;
|
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 {
|
export interface ProbeResultUI {
|
||||||
cpuPct: number;
|
cpuPct: number;
|
||||||
error: string;
|
error: string;
|
||||||
@@ -763,17 +625,6 @@ export interface Setting {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubBalancer {
|
|
||||||
createdAt: number;
|
|
||||||
enabled: boolean;
|
|
||||||
id: number;
|
|
||||||
inboundIds: number[];
|
|
||||||
remark: string;
|
|
||||||
sortOrder: number;
|
|
||||||
strategy: string;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
password: string;
|
password: string;
|
||||||
|
|||||||
+20
-171
@@ -15,6 +15,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
|
|||||||
export const SubLinkProviderSchema = z.unknown();
|
export const SubLinkProviderSchema = z.unknown();
|
||||||
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
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 const staticEgressResolverSchema = z.string();
|
||||||
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||||
|
|
||||||
@@ -27,83 +30,23 @@ export type transportBits = z.infer<typeof transportBitsSchema>;
|
|||||||
export const AllSettingSchema = z.object({
|
export const AllSettingSchema = z.object({
|
||||||
datepicker: z.string(),
|
datepicker: z.string(),
|
||||||
expireDiff: z.number().int().min(0),
|
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),
|
pageSize: z.number().int().min(0).max(1000),
|
||||||
panelOutbound: z.string(),
|
|
||||||
remarkTemplate: z.string(),
|
remarkTemplate: z.string(),
|
||||||
restartXrayOnClientDisable: z.boolean(),
|
restartXrayOnClientDisable: z.boolean(),
|
||||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
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(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: 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(),
|
subDomain: z.string(),
|
||||||
subEnable: z.boolean(),
|
subEnable: z.boolean(),
|
||||||
subEnableRouting: z.boolean(),
|
|
||||||
subEncrypt: z.boolean(),
|
subEncrypt: z.boolean(),
|
||||||
subHideSettings: z.boolean(),
|
subHideSettings: z.boolean(),
|
||||||
subIncyEnableRouting: z.boolean(),
|
|
||||||
subIncyRoutingRules: z.string(),
|
|
||||||
subJsonAlwaysArray: z.boolean(),
|
|
||||||
subJsonAutoDetect: z.boolean(),
|
|
||||||
subJsonEnable: z.boolean(),
|
|
||||||
subJsonFinalMask: z.string(),
|
|
||||||
subJsonMux: z.string(),
|
|
||||||
subJsonObservatory: z.string(),
|
|
||||||
subJsonPath: z.string(),
|
|
||||||
subJsonRules: z.string(),
|
|
||||||
subJsonURI: z.string(),
|
|
||||||
subJsonUserAgentRegex: z.string(),
|
|
||||||
subKeyFile: z.string(),
|
subKeyFile: z.string(),
|
||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(0).max(65535),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
|
||||||
subShowIdentityOnAllLinks: z.boolean(),
|
subShowIdentityOnAllLinks: z.boolean(),
|
||||||
subSupportUrl: z.string(),
|
subSupportUrl: z.string(),
|
||||||
subThemeDir: z.string(),
|
|
||||||
subTitle: z.string(),
|
subTitle: z.string(),
|
||||||
subURI: z.string(),
|
subURI: z.string(),
|
||||||
subUpdates: z.number().int().min(0).max(525600),
|
subUpdates: z.number().int().min(0).max(525600),
|
||||||
@@ -120,9 +63,8 @@ export const AllSettingSchema = z.object({
|
|||||||
tgRunTime: z.string(),
|
tgRunTime: z.string(),
|
||||||
timeLocation: z.string(),
|
timeLocation: z.string(),
|
||||||
trafficDiff: z.number().int().min(0).max(100),
|
trafficDiff: z.number().int().min(0).max(100),
|
||||||
|
trafficStatsSwitches: z.string(),
|
||||||
trustedProxyCIDRs: z.string(),
|
trustedProxyCIDRs: z.string(),
|
||||||
twoFactorEnable: z.boolean(),
|
|
||||||
twoFactorToken: z.string(),
|
|
||||||
warpUpdateInterval: z.number().int().min(0),
|
warpUpdateInterval: z.number().int().min(0),
|
||||||
webBasePath: z.string(),
|
webBasePath: z.string(),
|
||||||
webCertFile: z.string(),
|
webCertFile: z.string(),
|
||||||
@@ -136,90 +78,27 @@ export type AllSetting = z.infer<typeof AllSettingSchema>;
|
|||||||
export const AllSettingViewSchema = z.object({
|
export const AllSettingViewSchema = z.object({
|
||||||
datepicker: z.string(),
|
datepicker: z.string(),
|
||||||
expireDiff: z.number().int().min(0),
|
expireDiff: z.number().int().min(0),
|
||||||
externalTrafficInformEnable: z.boolean(),
|
|
||||||
externalTrafficInformURI: z.string(),
|
|
||||||
hasApiToken: z.boolean(),
|
hasApiToken: z.boolean(),
|
||||||
hasLdapPassword: z.boolean(),
|
|
||||||
hasNordSecret: z.boolean(),
|
hasNordSecret: z.boolean(),
|
||||||
hasSmtpPassword: z.boolean(),
|
|
||||||
hasTgBotToken: z.boolean(),
|
hasTgBotToken: z.boolean(),
|
||||||
hasTwoFactorToken: z.boolean(),
|
|
||||||
hasWarpSecret: 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),
|
pageSize: z.number().int().min(0).max(1000),
|
||||||
panelOutbound: z.string(),
|
|
||||||
remarkTemplate: z.string(),
|
remarkTemplate: z.string(),
|
||||||
restartXrayOnClientDisable: z.boolean(),
|
restartXrayOnClientDisable: z.boolean(),
|
||||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
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(),
|
subAnnounce: z.string(),
|
||||||
subCertFile: 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(),
|
subDomain: z.string(),
|
||||||
subEnable: z.boolean(),
|
subEnable: z.boolean(),
|
||||||
subEnableRouting: z.boolean(),
|
|
||||||
subEncrypt: z.boolean(),
|
subEncrypt: z.boolean(),
|
||||||
subHideSettings: z.boolean(),
|
subHideSettings: z.boolean(),
|
||||||
subIncyEnableRouting: z.boolean(),
|
|
||||||
subIncyRoutingRules: z.string(),
|
|
||||||
subJsonAlwaysArray: z.boolean(),
|
|
||||||
subJsonAutoDetect: z.boolean(),
|
|
||||||
subJsonEnable: z.boolean(),
|
|
||||||
subJsonFinalMask: z.string(),
|
|
||||||
subJsonMux: z.string(),
|
|
||||||
subJsonObservatory: z.string(),
|
|
||||||
subJsonPath: z.string(),
|
|
||||||
subJsonRules: z.string(),
|
|
||||||
subJsonURI: z.string(),
|
|
||||||
subJsonUserAgentRegex: z.string(),
|
|
||||||
subKeyFile: z.string(),
|
subKeyFile: z.string(),
|
||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(0).max(65535),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
|
||||||
subShowIdentityOnAllLinks: z.boolean(),
|
subShowIdentityOnAllLinks: z.boolean(),
|
||||||
subSupportUrl: z.string(),
|
subSupportUrl: z.string(),
|
||||||
subThemeDir: z.string(),
|
|
||||||
subTitle: z.string(),
|
subTitle: z.string(),
|
||||||
subURI: z.string(),
|
subURI: z.string(),
|
||||||
subUpdates: z.number().int().min(0).max(525600),
|
subUpdates: z.number().int().min(0).max(525600),
|
||||||
@@ -236,9 +115,8 @@ export const AllSettingViewSchema = z.object({
|
|||||||
tgRunTime: z.string(),
|
tgRunTime: z.string(),
|
||||||
timeLocation: z.string(),
|
timeLocation: z.string(),
|
||||||
trafficDiff: z.number().int().min(0).max(100),
|
trafficDiff: z.number().int().min(0).max(100),
|
||||||
|
trafficStatsSwitches: z.string(),
|
||||||
trustedProxyCIDRs: z.string(),
|
trustedProxyCIDRs: z.string(),
|
||||||
twoFactorEnable: z.boolean(),
|
|
||||||
twoFactorToken: z.string(),
|
|
||||||
warpUpdateInterval: z.number().int().min(0),
|
warpUpdateInterval: z.number().int().min(0),
|
||||||
webBasePath: z.string(),
|
webBasePath: z.string(),
|
||||||
webCertFile: z.string(),
|
webCertFile: z.string(),
|
||||||
@@ -249,13 +127,6 @@ export const AllSettingViewSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
|
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({
|
export const ApiTokenSchema = z.object({
|
||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
@@ -423,14 +294,6 @@ export const GeoFileSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type GeoFile = z.infer<typeof GeoFileSchema>;
|
export type GeoFile = z.infer<typeof GeoFileSchema>;
|
||||||
|
|
||||||
export const GeodataTokenIssueSchema = z.object({
|
|
||||||
code: z.string().optional(),
|
|
||||||
file: z.string().optional(),
|
|
||||||
reason: z.string(),
|
|
||||||
token: z.string(),
|
|
||||||
});
|
|
||||||
export type GeodataTokenIssue = z.infer<typeof GeodataTokenIssueSchema>;
|
|
||||||
|
|
||||||
export const HistoryOfSeedersSchema = z.object({
|
export const HistoryOfSeedersSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
seederName: z.string(),
|
seederName: z.string(),
|
||||||
@@ -517,6 +380,7 @@ export const InboundSchema = z.object({
|
|||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
expiryTime: z.number().int(),
|
expiryTime: z.number().int(),
|
||||||
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
|
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
|
||||||
|
forwarding: z.string(),
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
lastTrafficResetTime: z.number().int(),
|
lastTrafficResetTime: z.number().int(),
|
||||||
listen: z.string(),
|
listen: z.string(),
|
||||||
@@ -643,9 +507,10 @@ export const NodeMutationRequestSchema = z.object({
|
|||||||
inboundSyncMode: z.enum(['all', 'selected']),
|
inboundSyncMode: z.enum(['all', 'selected']),
|
||||||
inboundTags: z.array(z.string()),
|
inboundTags: z.array(z.string()),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
nodeType: z.enum(['traditional', 'agent']).optional(),
|
||||||
outboundTag: z.string(),
|
outboundTag: z.string(),
|
||||||
pinnedCertSha256: z.string(),
|
pinnedCertSha256: z.string(),
|
||||||
port: z.number().int().min(1).max(65535),
|
port: z.number().int().min(0).max(65535),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
scheme: z.enum(['http', 'https']),
|
scheme: z.enum(['http', 'https']),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||||
@@ -654,7 +519,11 @@ export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
|
|||||||
|
|
||||||
export const NodeViewSchema = z.object({
|
export const NodeViewSchema = z.object({
|
||||||
activeCount: z.number().int(),
|
activeCount: z.number().int(),
|
||||||
|
activeEmails: z.array(z.string()).optional(),
|
||||||
address: z.string(),
|
address: z.string(),
|
||||||
|
agentUpdateError: z.string().optional(),
|
||||||
|
agentUpdateState: z.string().optional(),
|
||||||
|
agentUpdateTarget: z.string().optional(),
|
||||||
allowPrivateAddress: z.boolean(),
|
allowPrivateAddress: z.boolean(),
|
||||||
basePath: z.string(),
|
basePath: z.string(),
|
||||||
clientCount: z.number().int(),
|
clientCount: z.number().int(),
|
||||||
@@ -663,7 +532,9 @@ export const NodeViewSchema = z.object({
|
|||||||
cpuPct: z.number(),
|
cpuPct: z.number(),
|
||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
depletedCount: z.number().int(),
|
depletedCount: z.number().int(),
|
||||||
|
depletedEmails: z.array(z.string()).optional(),
|
||||||
disabledCount: z.number().int(),
|
disabledCount: z.number().int(),
|
||||||
|
disabledEmails: z.array(z.string()).optional(),
|
||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
guid: z.string(),
|
guid: z.string(),
|
||||||
hasApiToken: z.boolean(),
|
hasApiToken: z.boolean(),
|
||||||
@@ -671,6 +542,8 @@ export const NodeViewSchema = z.object({
|
|||||||
inboundCount: z.number().int(),
|
inboundCount: z.number().int(),
|
||||||
inboundSyncMode: z.string(),
|
inboundSyncMode: z.string(),
|
||||||
inboundTags: z.array(z.string()),
|
inboundTags: z.array(z.string()),
|
||||||
|
ipv4: z.string().optional(),
|
||||||
|
ipv6: z.string().optional(),
|
||||||
lastError: z.string(),
|
lastError: z.string(),
|
||||||
lastHeartbeat: z.number().int(),
|
lastHeartbeat: z.number().int(),
|
||||||
latencyMs: z.number().int(),
|
latencyMs: z.number().int(),
|
||||||
@@ -678,7 +551,9 @@ export const NodeViewSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
netDown: z.number().int(),
|
netDown: z.number().int(),
|
||||||
netUp: z.number().int(),
|
netUp: z.number().int(),
|
||||||
|
nodeType: z.string().optional(),
|
||||||
onlineCount: z.number().int(),
|
onlineCount: z.number().int(),
|
||||||
|
onlineEmails: z.array(z.string()).optional(),
|
||||||
outboundTag: z.string(),
|
outboundTag: z.string(),
|
||||||
panelVersion: z.string(),
|
panelVersion: z.string(),
|
||||||
parentGuid: z.string().optional(),
|
parentGuid: z.string().optional(),
|
||||||
@@ -714,20 +589,6 @@ export const PanelUpdateStatusSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
|
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({
|
export const ProbeResultUISchema = z.object({
|
||||||
cpuPct: z.number(),
|
cpuPct: z.number(),
|
||||||
error: z.string(),
|
error: z.string(),
|
||||||
@@ -814,18 +675,6 @@ export const SettingSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type Setting = z.infer<typeof SettingSchema>;
|
export type Setting = z.infer<typeof SettingSchema>;
|
||||||
|
|
||||||
export const SubBalancerSchema = z.object({
|
|
||||||
createdAt: z.number().int(),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
id: z.number().int(),
|
|
||||||
inboundIds: z.array(z.number().int()),
|
|
||||||
remark: z.string().max(256),
|
|
||||||
sortOrder: z.number().int().min(1),
|
|
||||||
strategy: z.enum(['leastLoad', 'leastPing', 'random', 'roundRobin']),
|
|
||||||
updatedAt: z.number().int(),
|
|
||||||
});
|
|
||||||
export type SubBalancer = z.infer<typeof SubBalancerSchema>;
|
|
||||||
|
|
||||||
export const UserSchema = z.object({
|
export const UserSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as cons
|
|||||||
interface SubSettings {
|
interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
subClashURI: string;
|
|
||||||
subClashEnable: boolean;
|
|
||||||
publicHost: string;
|
publicHost: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +158,7 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UseClientsOptions {
|
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
|
// 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
|
// list whose result they never read, which on a large panel means a full
|
||||||
// summary aggregate every 5 seconds for nothing.
|
// summary aggregate every 5 seconds for nothing.
|
||||||
@@ -267,22 +263,9 @@ export function useClients(options: UseClientsOptions = {}) {
|
|||||||
() => ({
|
() => ({
|
||||||
enable: !!defaults.subEnable,
|
enable: !!defaults.subEnable,
|
||||||
subURI: (defaults.subURI as string) || '',
|
subURI: (defaults.subURI as string) || '',
|
||||||
subJsonURI: (defaults.subJsonURI as string) || '',
|
|
||||||
subJsonEnable: !!defaults.subJsonEnable,
|
|
||||||
subClashURI: (defaults.subClashURI as string) || '',
|
|
||||||
subClashEnable: !!defaults.subClashEnable,
|
|
||||||
publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
|
publicHost: (defaults.subDomain as string) || (defaults.webDomain as string) || '',
|
||||||
}),
|
}),
|
||||||
[
|
[defaults.subEnable, defaults.subURI, defaults.subDomain, defaults.webDomain],
|
||||||
defaults.subEnable,
|
|
||||||
defaults.subURI,
|
|
||||||
defaults.subJsonURI,
|
|
||||||
defaults.subJsonEnable,
|
|
||||||
defaults.subClashURI,
|
|
||||||
defaults.subClashEnable,
|
|
||||||
defaults.subDomain,
|
|
||||||
defaults.webDomain,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const ipLimitEnable = !!defaults.ipLimitEnable;
|
const ipLimitEnable = !!defaults.ipLimitEnable;
|
||||||
@@ -304,7 +287,6 @@ export function useClients(options: UseClientsOptions = {}) {
|
|||||||
return Promise.all([
|
return Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
|
||||||
]);
|
]);
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
@@ -328,22 +310,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({
|
const updateMut = useMutation({
|
||||||
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
|
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
|
||||||
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
|
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
|
||||||
@@ -583,20 +549,6 @@ export function useClients(options: UseClientsOptions = {}) {
|
|||||||
},
|
},
|
||||||
[bulkSetEnableMut],
|
[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(
|
const attach = useCallback(
|
||||||
(email: string, inboundIds: number[]) => {
|
(email: string, inboundIds: number[]) => {
|
||||||
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
|
||||||
@@ -807,8 +759,6 @@ export function useClients(options: UseClientsOptions = {}) {
|
|||||||
bulkAdjust,
|
bulkAdjust,
|
||||||
bulkEnable,
|
bulkEnable,
|
||||||
bulkDisable,
|
bulkDisable,
|
||||||
bulkAddToGroup,
|
|
||||||
bulkRemoveFromGroup,
|
|
||||||
attach,
|
attach,
|
||||||
setExternalLinks,
|
setExternalLinks,
|
||||||
bulkAttach,
|
bulkAttach,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { HttpUtil } from '@/utils';
|
|||||||
import { parseMsg } from '@/utils/zodValidate';
|
import { parseMsg } from '@/utils/zodValidate';
|
||||||
import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
||||||
|
|
||||||
type Calendar = 'gregorian' | 'jalalian';
|
type Calendar = 'gregorian';
|
||||||
|
|
||||||
let cachedValue: Calendar = 'gregorian';
|
let cachedValue: Calendar = 'gregorian';
|
||||||
let fetched = false;
|
let fetched = false;
|
||||||
|
|||||||
@@ -6,14 +6,9 @@ const TITLE_KEYS: Record<string, string> = {
|
|||||||
'/': 'menu.dashboard',
|
'/': 'menu.dashboard',
|
||||||
'/inbounds': 'menu.inbounds',
|
'/inbounds': 'menu.inbounds',
|
||||||
'/clients': 'menu.clients',
|
'/clients': 'menu.clients',
|
||||||
'/groups': 'menu.groups',
|
|
||||||
'/nodes': 'menu.nodes',
|
'/nodes': 'menu.nodes',
|
||||||
'/hosts': 'menu.hosts',
|
'/hosts': 'menu.hosts',
|
||||||
'/settings': 'menu.settings',
|
'/settings': 'menu.settings',
|
||||||
'/xray': 'menu.xray',
|
|
||||||
'/outbound': 'menu.outbounds',
|
|
||||||
'/routing': 'menu.routing',
|
|
||||||
'/api-docs': 'menu.apiDocs',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePageTitle() {
|
export function usePageTitle() {
|
||||||
@@ -22,7 +17,7 @@ export function usePageTitle() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const key = TITLE_KEYS[pathname];
|
const key = TITLE_KEYS[pathname];
|
||||||
const title = key ? t(key) : '3X-UI';
|
const title = key ? t(key) : 'L-UI';
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
document.title = host ? `${host} - ${title}` : title;
|
document.title = host ? `${host} - ${title}` : title;
|
||||||
}, [pathname, t]);
|
}, [pathname, t]);
|
||||||
|
|||||||
@@ -1,542 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { HttpUtil, Msg } from '@/utils';
|
|
||||||
import { parseMsg } from '@/utils/zodValidate';
|
|
||||||
import { keys } from '@/api/queryKeys';
|
|
||||||
import {
|
|
||||||
OutboundTrafficListSchema,
|
|
||||||
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 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;
|
|
||||||
|
|
||||||
export interface UseXraySettingResult {
|
|
||||||
fetched: boolean;
|
|
||||||
spinning: boolean;
|
|
||||||
saveDisabled: boolean;
|
|
||||||
fetchError: string;
|
|
||||||
xraySetting: string;
|
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
|
||||||
|
|
||||||
export async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
|
||||||
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
|
||||||
if (typeof msg.obj !== 'string')
|
|
||||||
throw new Error('Malformed xray config response: expected string');
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(msg.obj);
|
|
||||||
} catch (e) {
|
|
||||||
const err = e as Error;
|
|
||||||
throw new Error(`Malformed xray config response: ${err.message}`, { cause: e });
|
|
||||||
}
|
|
||||||
const result = XrayConfigPayloadSchema.safeParse(parsed);
|
|
||||||
if (!result.success) {
|
|
||||||
console.warn('[zod] xray/ config payload failed validation', result.error.issues);
|
|
||||||
return parsed as XrayConfigPayload;
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
|
|
||||||
const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, {
|
|
||||||
silent: true,
|
|
||||||
});
|
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
|
|
||||||
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
|
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useXraySetting(): UseXraySettingResult {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const configQuery = useQuery({
|
|
||||||
queryKey: keys.xray.config(),
|
|
||||||
queryFn: fetchXrayConfig,
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
|
|
||||||
const trafficQuery = useQuery({
|
|
||||||
queryKey: keys.xray.outboundsTraffic(),
|
|
||||||
queryFn: fetchOutboundsTraffic,
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [xraySetting, setXraySettingState] = useState('');
|
|
||||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
|
||||||
const [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]);
|
|
||||||
const subscriptionOutbounds = useMemo<unknown[]>(
|
|
||||||
() => config?.subscriptionOutbounds || [],
|
|
||||||
[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[]>([]);
|
|
||||||
|
|
||||||
const [syncedConfig, setSyncedConfig] = useState<XrayConfigPayload | undefined>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
xraySettingRef.current = xraySetting;
|
|
||||||
outboundTestUrlRef.current = outboundTestUrl;
|
|
||||||
savedXraySettingRef.current = savedXraySetting;
|
|
||||||
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
|
|
||||||
templateSettingsRef.current = templateSettings;
|
|
||||||
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Adopt a fetched config during render, so the editor never paints one frame
|
|
||||||
// of the previous config after a refetch. Local edits win over the refetch.
|
|
||||||
if (config && config !== syncedConfig) {
|
|
||||||
setSyncedConfig(config);
|
|
||||||
const isDirty =
|
|
||||||
savedXraySetting !== xraySetting ||
|
|
||||||
savedOutboundTestUrl !== normalizeOutboundTestUrl(outboundTestUrl);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetched = configQuery.data !== undefined || configQuery.isError;
|
|
||||||
const fetchError = configQuery.error ? (configQuery.error as Error).message : '';
|
|
||||||
|
|
||||||
const setXraySetting = useCallback((next: string) => {
|
|
||||||
setXraySettingState(next);
|
|
||||||
if (syncingRef.current) return;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(next);
|
|
||||||
syncingRef.current = true;
|
|
||||||
setTemplateSettingsState(parsed);
|
|
||||||
syncingRef.current = false;
|
|
||||||
} catch {
|
|
||||||
/* ignore — wait for user to finish */
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
|
|
||||||
setTemplateSettingsState((prev) => {
|
|
||||||
const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
|
|
||||||
if (next == null) return next;
|
|
||||||
if (!syncingRef.current) {
|
|
||||||
try {
|
|
||||||
syncingRef.current = true;
|
|
||||||
setXraySettingState(JSON.stringify(next, null, 2));
|
|
||||||
} finally {
|
|
||||||
syncingRef.current = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setOutboundTestUrl = useCallback((v: string) => {
|
|
||||||
setOutboundTestUrlState(v);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchAll = useCallback(async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
const fetchOutboundsTrafficCb = useCallback(async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
const saveMut = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const sentXraySetting = xraySettingRef.current;
|
|
||||||
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
|
||||||
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
|
||||||
xraySetting: sentXraySetting,
|
|
||||||
outboundTestUrl: sentTestUrl,
|
|
||||||
});
|
|
||||||
return { msg, sentXraySetting, sentTestUrl };
|
|
||||||
},
|
|
||||||
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
|
|
||||||
if (!msg?.success) return;
|
|
||||||
setSavedXraySetting(sentXraySetting);
|
|
||||||
setSavedOutboundTestUrl(sentTestUrl);
|
|
||||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetTrafficMut = useMutation({
|
|
||||||
mutationFn: (tag: string) => HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
|
|
||||||
onSuccess: (msg) => {
|
|
||||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetDefaultMut = useMutation({
|
|
||||||
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
|
|
||||||
const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
|
|
||||||
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
|
|
||||||
},
|
|
||||||
onSuccess: (msg) => {
|
|
||||||
if (msg?.success && msg.obj) {
|
|
||||||
const cloned = JSON.parse(JSON.stringify(msg.obj));
|
|
||||||
setTemplateSettings(cloned);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveAll = useCallback(async () => {
|
|
||||||
await saveMut.mutateAsync();
|
|
||||||
}, [saveMut]);
|
|
||||||
const resetOutboundsTraffic = useCallback(
|
|
||||||
async (tag: string) => {
|
|
||||||
await resetTrafficMut.mutateAsync(tag);
|
|
||||||
},
|
|
||||||
[resetTrafficMut],
|
|
||||||
);
|
|
||||||
const resetToDefault = useCallback(async () => {
|
|
||||||
await resetDefaultMut.mutateAsync();
|
|
||||||
}, [resetDefaultMut]);
|
|
||||||
|
|
||||||
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
|
||||||
|
|
||||||
// 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 outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
fetched,
|
|
||||||
spinning,
|
|
||||||
saveDisabled,
|
|
||||||
fetchError,
|
|
||||||
xraySetting,
|
|
||||||
setXraySetting,
|
|
||||||
templateSettings,
|
|
||||||
setTemplateSettings,
|
|
||||||
outboundTestUrl,
|
|
||||||
setOutboundTestUrl,
|
|
||||||
inboundTags,
|
|
||||||
clientReverseTags,
|
|
||||||
subscriptionOutbounds,
|
|
||||||
subscriptionOutboundTags,
|
|
||||||
outboundsTraffic,
|
|
||||||
outboundTestStates,
|
|
||||||
subscriptionTestStates,
|
|
||||||
testingAll,
|
|
||||||
fetchAll,
|
|
||||||
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
|
|
||||||
resetOutboundsTraffic,
|
|
||||||
testOutbound,
|
|
||||||
testSubscriptionOutbound,
|
|
||||||
testAllOutbounds,
|
|
||||||
saveAll,
|
|
||||||
resetToDefault,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
fetched,
|
|
||||||
spinning,
|
|
||||||
saveDisabled,
|
|
||||||
fetchError,
|
|
||||||
xraySetting,
|
|
||||||
setXraySetting,
|
|
||||||
templateSettings,
|
|
||||||
setTemplateSettings,
|
|
||||||
outboundTestUrl,
|
|
||||||
setOutboundTestUrl,
|
|
||||||
inboundTags,
|
|
||||||
clientReverseTags,
|
|
||||||
subscriptionOutbounds,
|
|
||||||
subscriptionOutboundTags,
|
|
||||||
outboundsTraffic,
|
|
||||||
outboundTestStates,
|
|
||||||
subscriptionTestStates,
|
|
||||||
testingAll,
|
|
||||||
fetchAll,
|
|
||||||
fetchOutboundsTrafficCb,
|
|
||||||
resetOutboundsTraffic,
|
|
||||||
testOutbound,
|
|
||||||
testSubscriptionOutbound,
|
|
||||||
testAllOutbounds,
|
|
||||||
saveAll,
|
|
||||||
resetToDefault,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,9 @@ import { Drawer, Layout, Menu } from 'antd';
|
|||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
ApartmentOutlined,
|
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
CodeOutlined,
|
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
GithubOutlined,
|
GithubOutlined,
|
||||||
@@ -19,10 +17,12 @@ import {
|
|||||||
ImportOutlined,
|
ImportOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MenuOutlined,
|
MenuOutlined,
|
||||||
|
MessageOutlined,
|
||||||
MoonFilled,
|
MoonFilled,
|
||||||
MoonOutlined,
|
MoonOutlined,
|
||||||
PushpinFilled,
|
PushpinFilled,
|
||||||
PushpinOutlined,
|
PushpinOutlined,
|
||||||
|
RadarChartOutlined,
|
||||||
ReadOutlined,
|
ReadOutlined,
|
||||||
SafetyOutlined,
|
SafetyOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
@@ -173,9 +173,7 @@ export default function AppSidebar() {
|
|||||||
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname, hash } = useLocation();
|
const { pathname, hash } = useLocation();
|
||||||
const { allSetting } = useAllSettings();
|
useAllSettings();
|
||||||
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
|
||||||
const showSubBalancers = !!allSetting.subJsonEnable;
|
|
||||||
|
|
||||||
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
||||||
const [pinned, setPinned] = useState(readSidebarPinned);
|
const [pinned, setPinned] = useState(readSidebarPinned);
|
||||||
@@ -214,14 +212,9 @@ export default function AppSidebar() {
|
|||||||
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
|
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
|
||||||
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
|
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
|
||||||
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
{ 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: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
||||||
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
|
{ 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: '/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') },
|
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||||
],
|
],
|
||||||
[t],
|
[t],
|
||||||
@@ -242,45 +235,38 @@ export default function AppSidebar() {
|
|||||||
icon: <SafetyOutlined />,
|
icon: <SafetyOutlined />,
|
||||||
label: t('pages.settings.securitySettings'),
|
label: t('pages.settings.securitySettings'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/settings#telegram',
|
||||||
|
icon: <MessageOutlined />,
|
||||||
|
label: t('pages.settings.TGBotSettings'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '/settings#subscription',
|
key: '/settings#subscription',
|
||||||
icon: <CloudServerOutlined />,
|
icon: <CloudServerOutlined />,
|
||||||
label: t('pages.settings.subSettings'),
|
label: t('pages.settings.subSettings'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/settings#public-page',
|
||||||
|
icon: <GlobalOutlined />,
|
||||||
|
label: t('pages.settings.probeThemes.title'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/settings#probe',
|
||||||
|
icon: <RadarChartOutlined />,
|
||||||
|
label: t('pages.settings.probe.title'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
if (showSubFormats) {
|
|
||||||
children.push({
|
|
||||||
key: '/settings#subscription-formats',
|
|
||||||
icon: <CodeOutlined />,
|
|
||||||
label: t('menu.subFormats'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (showSubBalancers) {
|
|
||||||
children.push({
|
|
||||||
key: '/settings#subscription-balancers',
|
|
||||||
icon: <ApartmentOutlined />,
|
|
||||||
label: t('pages.settings.subBalancers.menu'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return children;
|
return children;
|
||||||
}, [t, showSubFormats, showSubBalancers]);
|
}, [t]);
|
||||||
|
|
||||||
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
|
|
||||||
() => [{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') }],
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const settingsActive = pathname === '/settings';
|
const settingsActive = pathname === '/settings';
|
||||||
const xrayActive = pathname === '/xray';
|
|
||||||
const selectedKey = settingsActive
|
const selectedKey = settingsActive
|
||||||
? `/settings${hash || '#general'}`
|
? `/settings${hash || '#general'}`
|
||||||
: xrayActive
|
: pathname === ''
|
||||||
? `/xray${hash || '#basic'}`
|
? '/'
|
||||||
: pathname === ''
|
: pathname;
|
||||||
? '/'
|
|
||||||
: pathname;
|
|
||||||
|
|
||||||
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
|
const openSubmenu = settingsActive ? '/settings' : null;
|
||||||
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
|
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
|
||||||
if (openSubmenu && !openKeys.includes(openSubmenu)) {
|
if (openSubmenu && !openKeys.includes(openSubmenu)) {
|
||||||
setOpenKeys([...openKeys, openSubmenu]);
|
setOpenKeys([...openKeys, openSubmenu]);
|
||||||
@@ -293,12 +279,9 @@ export default function AppSidebar() {
|
|||||||
if (tab.key === '/settings') {
|
if (tab.key === '/settings') {
|
||||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
|
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
|
||||||
}
|
}
|
||||||
if (tab.key === '/xray') {
|
|
||||||
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
|
|
||||||
}
|
|
||||||
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
|
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
|
||||||
}),
|
}),
|
||||||
[settingsChildren, xrayChildren],
|
[settingsChildren],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openLink = useCallback(
|
const openLink = useCallback(
|
||||||
@@ -352,7 +335,7 @@ export default function AppSidebar() {
|
|||||||
>
|
>
|
||||||
<div className="sider-brand">
|
<div className="sider-brand">
|
||||||
<div className="brand-block">
|
<div className="brand-block">
|
||||||
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
|
<span className="brand-text">{railCollapsed ? 'L' : 'L-UI'}</span>
|
||||||
</div>
|
</div>
|
||||||
{!railCollapsed && (
|
{!railCollapsed && (
|
||||||
<div className="brand-actions">
|
<div className="brand-actions">
|
||||||
@@ -416,7 +399,7 @@ export default function AppSidebar() {
|
|||||||
>
|
>
|
||||||
<div className="drawer-header">
|
<div className="drawer-header">
|
||||||
<div className="brand-block">
|
<div className="brand-block">
|
||||||
<span className="drawer-brand">3X-UI</span>
|
<span className="drawer-brand">L-UI</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="drawer-header-actions">
|
<div className="drawer-header-actions">
|
||||||
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
<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 {
|
export function formatPanelVersion(version: string | undefined | null): string {
|
||||||
const v = (version || '').trim();
|
const v = (version || '').trim();
|
||||||
if (!v) return '';
|
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, '');
|
const normalized = v.replace(/^v/i, '');
|
||||||
return /^\d/.test(normalized) ? `v${normalized}` : v;
|
return /^\d/.test(normalized) ? `v${normalized}` : v;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -1,6 +1,16 @@
|
|||||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
|
||||||
import type { BalancerObject } from '@/schemas/routing';
|
import type { BalancerObject } from '@/schemas/routing';
|
||||||
|
|
||||||
|
// The settings object these helpers read/mutate: only the routing
|
||||||
|
// balancers and the two observatory sections are touched. (This was
|
||||||
|
// XraySettingsValue from the retired xray settings page's hook; the
|
||||||
|
// helpers outlived the page because the subscription balancers tab
|
||||||
|
// still consumes the defaults.)
|
||||||
|
export interface BalancerSettingsLike {
|
||||||
|
routing?: { balancers?: BalancerObject[] } | null;
|
||||||
|
observatory?: unknown;
|
||||||
|
burstObservatory?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_OBSERVATORY = Object.freeze({
|
export const DEFAULT_OBSERVATORY = Object.freeze({
|
||||||
subjectSelector: [] as string[],
|
subjectSelector: [] as string[],
|
||||||
probeURL: 'https://www.google.com/generate_204',
|
probeURL: 'https://www.google.com/generate_204',
|
||||||
@@ -34,7 +44,7 @@ export function balancerRequiresBurstObservatory(b: BalancerObject): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function settingsRequireBurstObservatory(t: XraySettingsValue | null): boolean {
|
export function settingsRequireBurstObservatory(t: BalancerSettingsLike | null): boolean {
|
||||||
const balancers = (t?.routing?.balancers || []) as BalancerObject[];
|
const balancers = (t?.routing?.balancers || []) as BalancerObject[];
|
||||||
return balancers.some(balancerRequiresBurstObservatory);
|
return balancers.some(balancerRequiresBurstObservatory);
|
||||||
}
|
}
|
||||||
@@ -59,7 +69,7 @@ export function settingsRequireBurstObservatory(t: XraySettingsValue | null): bo
|
|||||||
// the last leastLoad) removes the burst observer again. A no-fallback
|
// the last leastLoad) removes the burst observer again. A no-fallback
|
||||||
// Random/RoundRobin balancer never expands the observer either, because those
|
// Random/RoundRobin balancer never expands the observer either, because those
|
||||||
// strategies do not consume observer data.
|
// strategies do not consume observer data.
|
||||||
export function syncObservatories(t: XraySettingsValue) {
|
export function syncObservatories(t: BalancerSettingsLike) {
|
||||||
const balancers = (t.routing?.balancers || []) as BalancerObject[];
|
const balancers = (t.routing?.balancers || []) as BalancerObject[];
|
||||||
|
|
||||||
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
|
const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
|
||||||
@@ -62,7 +62,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
|||||||
<Select
|
<Select
|
||||||
mode="tags"
|
mode="tags"
|
||||||
tokenSeparators={[',']}
|
tokenSeparators={[',']}
|
||||||
placeholder="IP/CIDR/geoip:*/ext:*"
|
placeholder="IP/CIDR"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -73,7 +73,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
|
|||||||
<Select
|
<Select
|
||||||
mode="tags"
|
mode="tags"
|
||||||
tokenSeparators={[',']}
|
tokenSeparators={[',']}
|
||||||
placeholder="domain:*/ext:*"
|
placeholder="domain:*"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as SockoptForm } from './sockopt';
|
||||||
|
export { default as MuxForm } from './mux';
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import type { GeoKind } from '@/generated/types';
|
|
||||||
|
|
||||||
const DEFAULT_SITE_FILE = 'geosite.dat';
|
|
||||||
const DEFAULT_IP_FILE = 'geoip.dat';
|
|
||||||
|
|
||||||
const LONG_FORMS: Array<[RegExp, string]> = [
|
|
||||||
[/^ext(?:-domain|-site)?:geosite\.dat:/, 'geosite:'],
|
|
||||||
[/^ext(?:-ip)?:geoip\.dat:/, 'geoip:'],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function parseTokens(value: string): string[] {
|
|
||||||
return value
|
|
||||||
.split(',')
|
|
||||||
.map((token) => token.trim())
|
|
||||||
.filter((token) => token !== '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTokens(tokens: string[]): string {
|
|
||||||
return tokens.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tokenFor(file: string, code: string, kind: GeoKind): string {
|
|
||||||
if (kind === 'ip' && file === DEFAULT_IP_FILE) return `geoip:${code}`;
|
|
||||||
if (kind === 'site' && file === DEFAULT_SITE_FILE) return `geosite:${code}`;
|
|
||||||
return `ext:${file}:${code}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Xray treats category codes case-insensitively and accepts both the
|
|
||||||
* `geosite:cn` shorthand and its `ext:geosite.dat:cn` long form, so tokens are
|
|
||||||
* compared through this normal form. Only comparison uses it — whatever the
|
|
||||||
* user typed is what stays in the rule.
|
|
||||||
*/
|
|
||||||
export function canonicalToken(token: string): string {
|
|
||||||
const lowered = token.trim().toLowerCase();
|
|
||||||
for (const [pattern, shorthand] of LONG_FORMS) {
|
|
||||||
if (pattern.test(lowered)) return lowered.replace(pattern, shorthand);
|
|
||||||
}
|
|
||||||
return lowered;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectionFromValue(value: string, known: ReadonlySet<string>): string[] {
|
|
||||||
const canonicalKnown = new Set([...known].map(canonicalToken));
|
|
||||||
const selection: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const token of parseTokens(value)) {
|
|
||||||
const canonical = canonicalToken(token);
|
|
||||||
if (!canonicalKnown.has(canonical) || seen.has(canonical)) continue;
|
|
||||||
seen.add(canonical);
|
|
||||||
selection.push(token);
|
|
||||||
}
|
|
||||||
return selection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeSelection(
|
|
||||||
value: string,
|
|
||||||
selected: string[],
|
|
||||||
known: ReadonlySet<string>,
|
|
||||||
): string {
|
|
||||||
const canonicalKnown = new Set([...known].map(canonicalToken));
|
|
||||||
const kept = new Set(
|
|
||||||
selected.map((token) => canonicalToken(token)).filter((token) => token !== ''),
|
|
||||||
);
|
|
||||||
const merged: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const append = (token: string) => {
|
|
||||||
const canonical = canonicalToken(token);
|
|
||||||
if (canonical === '' || seen.has(canonical)) return;
|
|
||||||
seen.add(canonical);
|
|
||||||
merged.push(token);
|
|
||||||
};
|
|
||||||
for (const token of parseTokens(value)) {
|
|
||||||
const canonical = canonicalToken(token);
|
|
||||||
if (canonicalKnown.has(canonical) && !kept.has(canonical)) continue;
|
|
||||||
append(token);
|
|
||||||
}
|
|
||||||
for (const token of selected) {
|
|
||||||
append(token.trim());
|
|
||||||
}
|
|
||||||
return formatTokens(merged);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type {
|
import {
|
||||||
InboundFormValues,
|
ForwardingSpecSchema,
|
||||||
ShareAddrStrategy,
|
type ForwardingSpec,
|
||||||
TrafficReset,
|
type InboundFormValues,
|
||||||
|
type ShareAddrStrategy,
|
||||||
|
type TrafficReset,
|
||||||
} from '@/schemas/forms/inbound-form';
|
} from '@/schemas/forms/inbound-form';
|
||||||
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
import type { InboundSettings } from '@/schemas/protocols/inbound';
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +51,7 @@ export interface RawInboundRow {
|
|||||||
trafficResetDay?: number;
|
trafficResetDay?: number;
|
||||||
lastTrafficResetTime?: number;
|
lastTrafficResetTime?: number;
|
||||||
nodeId?: number | null;
|
nodeId?: number | null;
|
||||||
|
forwarding?: unknown;
|
||||||
shareAddrStrategy?: string;
|
shareAddrStrategy?: string;
|
||||||
shareAddr?: string;
|
shareAddr?: string;
|
||||||
subSortIndex?: number;
|
subSortIndex?: number;
|
||||||
@@ -77,7 +80,8 @@ export interface WireInboundPayload {
|
|||||||
sniffing: string;
|
sniffing: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
clientStats?: unknown;
|
clientStats?: unknown;
|
||||||
nodeId?: number;
|
nodeId?: number | null;
|
||||||
|
forwarding: string;
|
||||||
shareAddrStrategy: ShareAddrStrategy;
|
shareAddrStrategy: ShareAddrStrategy;
|
||||||
shareAddr: string;
|
shareAddr: string;
|
||||||
subSortIndex: number;
|
subSortIndex: number;
|
||||||
@@ -111,6 +115,11 @@ function coerceTrafficReset(v: unknown): TrafficReset {
|
|||||||
: 'never';
|
: 'never';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coerceForwarding(v: unknown): ForwardingSpec {
|
||||||
|
const parsed = ForwardingSpecSchema.safeParse(coerceJsonObject(v));
|
||||||
|
return parsed.success ? parsed.data : ForwardingSpecSchema.parse({});
|
||||||
|
}
|
||||||
|
|
||||||
function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
|
function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
|
||||||
return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
|
return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
|
||||||
? (v as ShareAddrStrategy)
|
? (v as ShareAddrStrategy)
|
||||||
@@ -213,6 +222,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
|||||||
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
|
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
|
||||||
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
|
||||||
nodeId: row.nodeId ?? null,
|
nodeId: row.nodeId ?? null,
|
||||||
|
forwarding: coerceForwarding(row.forwarding),
|
||||||
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
|
||||||
shareAddr: row.shareAddr ?? '',
|
shareAddr: row.shareAddr ?? '',
|
||||||
subSortIndex: Math.max(1, row.subSortIndex ?? 1),
|
subSortIndex: Math.max(1, row.subSortIndex ?? 1),
|
||||||
@@ -383,7 +393,16 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
|||||||
shareAddr: values.shareAddr,
|
shareAddr: values.shareAddr,
|
||||||
subSortIndex: values.subSortIndex,
|
subSortIndex: values.subSortIndex,
|
||||||
disableFlow: values.disableFlow,
|
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;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
|
|||||||
|
|
||||||
import { getHeaderValue } from './headers';
|
import { getHeaderValue } from './headers';
|
||||||
import { canEnableTlsFlow } from './protocol-capabilities';
|
import { canEnableTlsFlow } from './protocol-capabilities';
|
||||||
import { deriveSpiderX } from './spider-x';
|
|
||||||
|
|
||||||
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
// Share-link generators. Each per-protocol fn takes a typed inbound plus
|
||||||
// client overrides and returns a URL (or '' when the protocol doesn't
|
// client overrides and returns a URL (or '' when the protocol doesn't
|
||||||
@@ -353,7 +352,6 @@ export interface GenVlessLinkInput {
|
|||||||
forceTls?: ForceTls;
|
forceTls?: ForceTls;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientKey?: string;
|
|
||||||
flow?: VlessClient['flow'];
|
flow?: VlessClient['flow'];
|
||||||
externalProxy?: ExternalProxyEntry | null;
|
externalProxy?: ExternalProxyEntry | null;
|
||||||
}
|
}
|
||||||
@@ -374,7 +372,7 @@ export function applyVlessRoute(id: string, route: string | undefined): string {
|
|||||||
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
|
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
|
||||||
// query carries network type, encryption, network-specific knobs, and
|
// query carries network type, encryption, network-specific knobs, and
|
||||||
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
|
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
|
||||||
// pbk/sid/spx). Returns '' if the inbound isn't vless.
|
// pbk/sid). Returns '' if the inbound isn't vless.
|
||||||
export function genVlessLink(input: GenVlessLinkInput): string {
|
export function genVlessLink(input: GenVlessLinkInput): string {
|
||||||
const {
|
const {
|
||||||
inbound,
|
inbound,
|
||||||
@@ -383,7 +381,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
|||||||
forceTls = 'same',
|
forceTls = 'same',
|
||||||
remark = '',
|
remark = '',
|
||||||
clientId,
|
clientId,
|
||||||
clientKey = '',
|
|
||||||
flow = '',
|
flow = '',
|
||||||
externalProxy = null,
|
externalProxy = null,
|
||||||
} = input;
|
} = input;
|
||||||
@@ -462,8 +459,6 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
|||||||
if (sni && sni.length > 0) params.set('sni', sni);
|
if (sni && sni.length > 0) params.set('sni', sni);
|
||||||
|
|
||||||
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||||
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
|
||||||
if (spx.length > 0) params.set('spx', spx);
|
|
||||||
if (reality.settings.mldsa65Verify.length > 0)
|
if (reality.settings.mldsa65Verify.length > 0)
|
||||||
params.set('pqv', reality.settings.mldsa65Verify);
|
params.set('pqv', reality.settings.mldsa65Verify);
|
||||||
}
|
}
|
||||||
@@ -560,7 +555,6 @@ function writeTlsParams(
|
|||||||
function writeRealityParams(
|
function writeRealityParams(
|
||||||
stream: NonNullable<Inbound['streamSettings']>,
|
stream: NonNullable<Inbound['streamSettings']>,
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
clientKey: string,
|
|
||||||
): void {
|
): void {
|
||||||
if (stream.security !== 'reality') return;
|
if (stream.security !== 'reality') return;
|
||||||
const reality = stream.realitySettings;
|
const reality = stream.realitySettings;
|
||||||
@@ -573,8 +567,6 @@ function writeRealityParams(
|
|||||||
if (sni && sni.length > 0) params.set('sni', sni);
|
if (sni && sni.length > 0) params.set('sni', sni);
|
||||||
|
|
||||||
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
if (reality.shortIds.length > 0) params.set('sid', reality.shortIds[0]);
|
||||||
const spx = deriveSpiderX(reality.settings.spiderX, clientKey);
|
|
||||||
if (spx.length > 0) params.set('spx', spx);
|
|
||||||
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
if (reality.settings.mldsa65Verify.length > 0) params.set('pqv', reality.settings.mldsa65Verify);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,7 +577,6 @@ export interface GenTrojanLinkInput {
|
|||||||
forceTls?: ForceTls;
|
forceTls?: ForceTls;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
clientPassword: string;
|
clientPassword: string;
|
||||||
clientKey?: string;
|
|
||||||
externalProxy?: ExternalProxyEntry | null;
|
externalProxy?: ExternalProxyEntry | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,7 +591,6 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
|||||||
forceTls = 'same',
|
forceTls = 'same',
|
||||||
remark = '',
|
remark = '',
|
||||||
clientPassword,
|
clientPassword,
|
||||||
clientKey = '',
|
|
||||||
externalProxy = null,
|
externalProxy = null,
|
||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
@@ -621,7 +611,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
|||||||
applyExternalProxyTLSParams(externalProxy, params, security);
|
applyExternalProxyTLSParams(externalProxy, params, security);
|
||||||
} else if (security === 'reality') {
|
} else if (security === 'reality') {
|
||||||
params.set('security', 'reality');
|
params.set('security', 'reality');
|
||||||
writeRealityParams(stream, params, clientKey);
|
writeRealityParams(stream, params);
|
||||||
} else {
|
} else {
|
||||||
params.set('security', 'none');
|
params.set('security', 'none');
|
||||||
}
|
}
|
||||||
@@ -1223,6 +1213,31 @@ export interface ShareHostFields {
|
|||||||
shareAddrStrategy?: string;
|
shareAddrStrategy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The subset of a node record the share-host pick reads.
|
||||||
|
export interface NodeHostFields {
|
||||||
|
address?: string;
|
||||||
|
ipv4?: string;
|
||||||
|
ipv6?: string;
|
||||||
|
nodeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// shareHostForNode picks the hostOverride for share/QR/export links from a
|
||||||
|
// node record. Agent-managed nodes carry the literal placeholder "agent" in
|
||||||
|
// address — the create form's sentinel, not a connectable host — while the
|
||||||
|
// backend-resolved externally usable address rides in ipv4/ipv6
|
||||||
|
// (annotateAgentTelemetry), which is what every other surface renders.
|
||||||
|
// Resolved addresses therefore win; the raw address is only a fallback and
|
||||||
|
// only when it is not the placeholder, so the placeholder can never leak
|
||||||
|
// into a link. With no resolved address the pick is empty and
|
||||||
|
// resolveShareHost's fallback chain (listen → panel host) continues.
|
||||||
|
export function shareHostForNode(node: NodeHostFields | undefined | null): string {
|
||||||
|
if (!node) return '';
|
||||||
|
const resolved = (node.ipv4 ?? '').trim() || (node.ipv6 ?? '').trim();
|
||||||
|
if (resolved) return resolved;
|
||||||
|
const raw = (node.address ?? '').trim();
|
||||||
|
return raw.toLowerCase() === 'agent' ? '' : raw;
|
||||||
|
}
|
||||||
|
|
||||||
// resolveShareHost picks the host that goes into share/QR links, the browser-side
|
// resolveShareHost picks the host that goes into share/QR links, the browser-side
|
||||||
// analog of the backend resolveInboundAddress. hostOverride is the hosting node's
|
// analog of the backend resolveInboundAddress. hostOverride is the hosting node's
|
||||||
// address (empty for this panel's own inbounds); fallbackHostname is the
|
// address (empty for this panel's own inbounds); fallbackHostname is the
|
||||||
@@ -1296,12 +1311,6 @@ type ClientShape = {
|
|||||||
subId?: string;
|
subId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mirror of the Go subKey: the stable per-client identity spx derivation
|
|
||||||
// keys on — subscription id first, unique email as the fallback.
|
|
||||||
function clientSubKey(client: ClientShape): string {
|
|
||||||
return client.subId || client.email || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInboundClients(inbound: Inbound): ClientShape[] | null {
|
export function getInboundClients(inbound: Inbound): ClientShape[] | null {
|
||||||
switch (inbound.protocol) {
|
switch (inbound.protocol) {
|
||||||
case 'vmess':
|
case 'vmess':
|
||||||
@@ -1367,7 +1376,6 @@ export function genLink(input: GenLinkInput): string {
|
|||||||
forceTls,
|
forceTls,
|
||||||
remark,
|
remark,
|
||||||
clientId: client.id ?? '',
|
clientId: client.id ?? '',
|
||||||
clientKey: clientSubKey(client),
|
|
||||||
flow: client.flow,
|
flow: client.flow,
|
||||||
externalProxy,
|
externalProxy,
|
||||||
});
|
});
|
||||||
@@ -1391,7 +1399,6 @@ export function genLink(input: GenLinkInput): string {
|
|||||||
forceTls,
|
forceTls,
|
||||||
remark,
|
remark,
|
||||||
clientPassword: client.password ?? '',
|
clientPassword: client.password ?? '',
|
||||||
clientKey: clientSubKey(client),
|
|
||||||
externalProxy,
|
externalProxy,
|
||||||
});
|
});
|
||||||
case 'hysteria':
|
case 'hysteria':
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { sha256 } from '@noble/hashes/sha2.js';
|
|
||||||
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
||||||
|
|
||||||
// Mirrors deriveSpiderX in internal/sub/service.go byte-for-byte so panel
|
|
||||||
// links and subscription links agree; returns '' when there is no seed and
|
|
||||||
// no client key (the caller then omits spx, as the legacy builder did).
|
|
||||||
export function deriveSpiderX(seed: string, clientKey: string): string {
|
|
||||||
if (!seed && !clientKey) return '';
|
|
||||||
return `/${bytesToHex(sha256(utf8ToBytes(`${seed}|${clientKey}`))).slice(0, 15)}`;
|
|
||||||
}
|
|
||||||
@@ -39,6 +39,7 @@ export type DBInboundInit = Partial<{
|
|||||||
streamSettings: RawJsonField;
|
streamSettings: RawJsonField;
|
||||||
tag: string;
|
tag: string;
|
||||||
sniffing: RawJsonField;
|
sniffing: RawJsonField;
|
||||||
|
forwarding: string;
|
||||||
clientStats: ClientStats[];
|
clientStats: ClientStats[];
|
||||||
nodeId: number | null;
|
nodeId: number | null;
|
||||||
shareAddrStrategy: string;
|
shareAddrStrategy: string;
|
||||||
@@ -88,6 +89,7 @@ export class DBInbound {
|
|||||||
streamSettings: RawJsonField;
|
streamSettings: RawJsonField;
|
||||||
tag: string;
|
tag: string;
|
||||||
sniffing: RawJsonField;
|
sniffing: RawJsonField;
|
||||||
|
forwarding: string;
|
||||||
clientStats: ClientStats[];
|
clientStats: ClientStats[];
|
||||||
nodeId: number | null;
|
nodeId: number | null;
|
||||||
shareAddrStrategy: string;
|
shareAddrStrategy: string;
|
||||||
@@ -119,6 +121,7 @@ export class DBInbound {
|
|||||||
this.streamSettings = '';
|
this.streamSettings = '';
|
||||||
this.tag = '';
|
this.tag = '';
|
||||||
this.sniffing = '';
|
this.sniffing = '';
|
||||||
|
this.forwarding = '';
|
||||||
this.clientStats = [];
|
this.clientStats = [];
|
||||||
this.nodeId = null;
|
this.nodeId = null;
|
||||||
this.shareAddrStrategy = 'node';
|
this.shareAddrStrategy = 'node';
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ export class AllSetting {
|
|||||||
webBasePath = '/';
|
webBasePath = '/';
|
||||||
sessionMaxAge = 360;
|
sessionMaxAge = 360;
|
||||||
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
|
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
|
||||||
ipLimitAllowlist = '';
|
|
||||||
panelOutbound = '';
|
|
||||||
pageSize = 25;
|
pageSize = 25;
|
||||||
expireDiff = 0;
|
expireDiff = 0;
|
||||||
trafficDiff = 0;
|
trafficDiff = 0;
|
||||||
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
|
||||||
subShowIdentityOnAllLinks = false;
|
subShowIdentityOnAllLinks = false;
|
||||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
datepicker = 'gregorian' as const;
|
||||||
tgBotEnable = false;
|
tgBotEnable = false;
|
||||||
tgBotToken = '';
|
tgBotToken = '';
|
||||||
tgBotAPIServer = '';
|
tgBotAPIServer = '';
|
||||||
@@ -26,97 +24,32 @@ export class AllSetting {
|
|||||||
tgCpu = 80;
|
tgCpu = 80;
|
||||||
tgMemory = 80;
|
tgMemory = 80;
|
||||||
tgLang = 'en-US';
|
tgLang = 'en-US';
|
||||||
twoFactorEnable = false;
|
trafficStatsSwitches = '';
|
||||||
twoFactorToken = '';
|
|
||||||
xrayTemplateConfig = '';
|
|
||||||
subEnable = true;
|
subEnable = true;
|
||||||
subJsonEnable = false;
|
|
||||||
subJsonAutoDetect = false;
|
|
||||||
subJsonAlwaysArray = false;
|
|
||||||
subJsonUserAgentRegex = '';
|
|
||||||
subClashAutoDetect = false;
|
|
||||||
subClashUserAgentRegex = '';
|
|
||||||
subTitle = '';
|
subTitle = '';
|
||||||
subSupportUrl = '';
|
subSupportUrl = '';
|
||||||
subProfileUrl = '';
|
subProfileUrl = '';
|
||||||
subAnnounce = '';
|
subAnnounce = '';
|
||||||
subEnableRouting = false;
|
|
||||||
subRoutingRules = '';
|
|
||||||
subIncyEnableRouting = false;
|
|
||||||
subIncyRoutingRules = '';
|
|
||||||
subListen = '';
|
subListen = '';
|
||||||
subPort = 2096;
|
subPort = 2096;
|
||||||
subPath = '/sub/';
|
subPath = '/sub/';
|
||||||
subJsonPath = '/json/';
|
|
||||||
subClashEnable = false;
|
|
||||||
subClashPath = '/clash/';
|
|
||||||
subDomain = '';
|
subDomain = '';
|
||||||
externalTrafficInformEnable = false;
|
|
||||||
externalTrafficInformURI = '';
|
|
||||||
restartXrayOnClientDisable = true;
|
restartXrayOnClientDisable = true;
|
||||||
subCertFile = '';
|
subCertFile = '';
|
||||||
subKeyFile = '';
|
subKeyFile = '';
|
||||||
subUpdates = 12;
|
subUpdates = 12;
|
||||||
subEncrypt = true;
|
subEncrypt = true;
|
||||||
subURI = '';
|
subURI = '';
|
||||||
subJsonURI = '';
|
|
||||||
subClashURI = '';
|
|
||||||
subClashEnableRouting = false;
|
|
||||||
subClashRules = '';
|
|
||||||
subJsonMux = '';
|
|
||||||
subJsonRules = '';
|
|
||||||
subJsonFinalMask = '';
|
|
||||||
subJsonObservatory = '';
|
|
||||||
subThemeDir = '';
|
|
||||||
subHideSettings = false;
|
subHideSettings = false;
|
||||||
|
|
||||||
timeLocation = 'Local';
|
timeLocation = 'Local';
|
||||||
|
|
||||||
ldapEnable = false;
|
|
||||||
ldapHost = '';
|
|
||||||
ldapPort = 389;
|
|
||||||
ldapUseTLS = false;
|
|
||||||
ldapInsecureSkipVerify = false;
|
|
||||||
ldapBindDN = '';
|
|
||||||
ldapPassword = '';
|
|
||||||
ldapBaseDN = '';
|
|
||||||
ldapUserFilter = '(objectClass=person)';
|
|
||||||
ldapUserAttr = 'mail';
|
|
||||||
ldapVlessField = 'vless_enabled';
|
|
||||||
ldapSyncCron = '@every 1m';
|
|
||||||
ldapFlagField = '';
|
|
||||||
ldapTruthyValues = 'true,1,yes,on';
|
|
||||||
ldapInvertFlag = false;
|
|
||||||
ldapInboundTags = '';
|
|
||||||
ldapAutoCreate = false;
|
|
||||||
ldapAutoDelete = false;
|
|
||||||
ldapDefaultTotalGB = 0;
|
|
||||||
ldapDefaultExpiryDays = 0;
|
|
||||||
ldapDefaultLimitIP = 0;
|
|
||||||
tgEnabledEvents = 'login.attempt,cpu.high';
|
tgEnabledEvents = 'login.attempt,cpu.high';
|
||||||
smtpEnable = false;
|
|
||||||
smtpHost = '';
|
|
||||||
smtpPort = 587;
|
|
||||||
smtpUsername = '';
|
|
||||||
smtpPassword = '';
|
|
||||||
smtpFrom = '';
|
|
||||||
smtpFromName = '';
|
|
||||||
smtpTo = '';
|
|
||||||
smtpEncryptionType = 'starttls';
|
|
||||||
smtpEnabledEvents = 'login.attempt,cpu.high';
|
|
||||||
smtpCpu = 80;
|
|
||||||
smtpMemory = 80;
|
|
||||||
outboundDownThreshold = 3;
|
|
||||||
hasTgBotToken = false;
|
hasTgBotToken = false;
|
||||||
hasTwoFactorToken = false;
|
|
||||||
hasLdapPassword = false;
|
|
||||||
hasApiToken = false;
|
hasApiToken = false;
|
||||||
hasWarpSecret = false;
|
hasWarpSecret = false;
|
||||||
hasNordSecret = false;
|
hasNordSecret = false;
|
||||||
hasSmtpPassword = false;
|
|
||||||
clearTgBotToken = false;
|
clearTgBotToken = false;
|
||||||
clearLdapPassword = false;
|
|
||||||
clearSmtpPassword = false;
|
|
||||||
|
|
||||||
constructor(data?: unknown) {
|
constructor(data?: unknown) {
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
@@ -124,10 +57,6 @@ export class AllSetting {
|
|||||||
}
|
}
|
||||||
const cpu = Math.round(Number(this.tgCpu));
|
const cpu = Math.round(Number(this.tgCpu));
|
||||||
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||||
const threshold = Math.round(Number(this.outboundDownThreshold));
|
|
||||||
this.outboundDownThreshold = Number.isFinite(threshold)
|
|
||||||
? Math.min(100, Math.max(1, threshold))
|
|
||||||
: 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: AllSetting): boolean {
|
equals(other: AllSetting): boolean {
|
||||||
|
|||||||
@@ -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',
|
'hysteria',
|
||||||
'wireguard',
|
'wireguard',
|
||||||
'amneziawg',
|
'amneziawg',
|
||||||
|
'socks',
|
||||||
|
'mixed',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const EMPTY: ClientBulkAddFormValues = {
|
const EMPTY: ClientBulkAddFormValues = {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
|||||||
'wireguard',
|
'wireguard',
|
||||||
'mtproto',
|
'mtproto',
|
||||||
'amneziawg',
|
'amneziawg',
|
||||||
|
'socks',
|
||||||
|
'mixed',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
||||||
|
|||||||
@@ -51,10 +51,6 @@ const INBOUND_CHIP_LIMIT = 1;
|
|||||||
interface SubSettings {
|
interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
subClashURI: string;
|
|
||||||
subClashEnable: boolean;
|
|
||||||
publicHost?: string;
|
publicHost?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,17 +72,11 @@ interface ApiMsg<T = unknown> {
|
|||||||
const DEFAULT_SUB: SubSettings = {
|
const DEFAULT_SUB: SubSettings = {
|
||||||
enable: false,
|
enable: false,
|
||||||
subURI: '',
|
subURI: '',
|
||||||
subJsonURI: '',
|
|
||||||
subJsonEnable: false,
|
|
||||||
subClashURI: '',
|
|
||||||
subClashEnable: false,
|
|
||||||
publicHost: '',
|
publicHost: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUBSCRIPTION_DOWNLOAD_NAMES = {
|
const SUBSCRIPTION_DOWNLOAD_NAMES = {
|
||||||
standard: 'subscription-standard.txt',
|
standard: 'subscription-standard.txt',
|
||||||
json: 'subscription-json.json',
|
|
||||||
clash: 'subscription-clash.yaml',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export default function ClientInfoModal({
|
export default function ClientInfoModal({
|
||||||
@@ -170,14 +160,6 @@ export default function ClientInfoModal({
|
|||||||
|
|
||||||
const subId = client?.subId;
|
const subId = client?.subId;
|
||||||
const subLink = subId && subSettings?.subURI ? subSettings.subURI + subId : '';
|
const subLink = subId && subSettings?.subURI ? subSettings.subURI + subId : '';
|
||||||
const subJsonLink =
|
|
||||||
subId && subSettings?.subJsonEnable && subSettings?.subJsonURI
|
|
||||||
? subSettings.subJsonURI + subId
|
|
||||||
: '';
|
|
||||||
const subClashLink =
|
|
||||||
subId && subSettings?.subClashEnable && subSettings?.subClashURI
|
|
||||||
? subSettings.subClashURI + subId
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const showSubscription = !!(subSettings?.enable && client?.subId);
|
const showSubscription = !!(subSettings?.enable && client?.subId);
|
||||||
const wgInbound = useMemo(
|
const wgInbound = useMemo(
|
||||||
@@ -611,120 +593,6 @@ export default function ClientInfoModal({
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{subJsonLink && (
|
|
||||||
<div className="link-row">
|
|
||||||
<Tag color="purple" className="link-row-tag">
|
|
||||||
JSON
|
|
||||||
</Tag>
|
|
||||||
<a
|
|
||||||
href={subJsonLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="link-row-title link-row-title-anchor"
|
|
||||||
title={subJsonLink}
|
|
||||||
>
|
|
||||||
{client.subId}
|
|
||||||
</a>
|
|
||||||
<div className="link-row-actions">
|
|
||||||
<Tooltip title={t('copy')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
onClick={() => copyValue(subJsonLink)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('download')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
aria-label={t('download')}
|
|
||||||
loading={downloadingFormat === 'json'}
|
|
||||||
disabled={downloadingFormat !== null}
|
|
||||||
onClick={() => void downloadSubscription(subJsonLink, 'json')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<QrPanel
|
|
||||||
value={subJsonLink}
|
|
||||||
remark={`${client.email} — JSON`}
|
|
||||||
size={220}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tooltip title={t('pages.clients.qrCode')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label={t('pages.clients.qrCode')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subClashLink && (
|
|
||||||
<div className="link-row">
|
|
||||||
<Tooltip title="Clash / Mihomo">
|
|
||||||
<Tag color="gold" className="link-row-tag">
|
|
||||||
CLASH
|
|
||||||
</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
<a
|
|
||||||
href={subClashLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="link-row-title link-row-title-anchor"
|
|
||||||
title={subClashLink}
|
|
||||||
>
|
|
||||||
{client.subId}
|
|
||||||
</a>
|
|
||||||
<div className="link-row-actions">
|
|
||||||
<Tooltip title={t('copy')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
onClick={() => copyValue(subClashLink)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t('download')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<DownloadOutlined />}
|
|
||||||
aria-label={t('download')}
|
|
||||||
loading={downloadingFormat === 'clash'}
|
|
||||||
disabled={downloadingFormat !== null}
|
|
||||||
onClick={() => void downloadSubscription(subClashLink, 'clash')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="left"
|
|
||||||
destroyOnHidden
|
|
||||||
content={
|
|
||||||
<QrPanel
|
|
||||||
value={subClashLink}
|
|
||||||
remark={`${client.email} — Clash / Mihomo`}
|
|
||||||
size={220}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Tooltip title={t('pages.clients.qrCode')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<QrcodeOutlined />}
|
|
||||||
aria-label={t('pages.clients.qrCode')}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ import {
|
|||||||
interface SubSettings {
|
interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
publicHost?: string;
|
publicHost?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +40,6 @@ interface ApiMsg<T = unknown> {
|
|||||||
const DEFAULT_SUB: SubSettings = {
|
const DEFAULT_SUB: SubSettings = {
|
||||||
enable: false,
|
enable: false,
|
||||||
subURI: '',
|
subURI: '',
|
||||||
subJsonURI: '',
|
|
||||||
subJsonEnable: false,
|
|
||||||
publicHost: '',
|
publicHost: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,10 +58,6 @@ export default function ClientQrModal({
|
|||||||
const subId = client?.subId;
|
const subId = client?.subId;
|
||||||
const subEnabled = !!subSettings?.enable;
|
const subEnabled = !!subSettings?.enable;
|
||||||
const subLink = subId && subEnabled && subSettings?.subURI ? subSettings.subURI + subId : '';
|
const subLink = subId && subEnabled && subSettings?.subURI ? subSettings.subURI + subId : '';
|
||||||
const subJsonLink =
|
|
||||||
subId && subEnabled && subSettings?.subJsonEnable && subSettings?.subJsonURI
|
|
||||||
? subSettings.subJsonURI + subId
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const wgInbound = useMemo(
|
const wgInbound = useMemo(
|
||||||
() => findWireguardInbound(client, inboundsById),
|
() => findWireguardInbound(client, inboundsById),
|
||||||
@@ -97,8 +89,7 @@ export default function ClientQrModal({
|
|||||||
);
|
);
|
||||||
}, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
|
}, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
|
||||||
|
|
||||||
const hasAnything =
|
const hasAnything = !!subLink || !!wgConfigText || !!awgConfigText || links.length > 0;
|
||||||
!!subLink || !!subJsonLink || !!wgConfigText || !!awgConfigText || links.length > 0;
|
|
||||||
|
|
||||||
// The reset runs during render so the effect only carries the request.
|
// The reset runs during render so the effect only carries the request.
|
||||||
const openSubId = open ? (client?.subId ?? '') : '';
|
const openSubId = open ? (client?.subId ?? '') : '';
|
||||||
@@ -142,13 +133,6 @@ export default function ClientQrModal({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (subJsonLink) {
|
|
||||||
out.push({
|
|
||||||
key: 'subJson',
|
|
||||||
label: `${t('subscription.title')} (JSON)`,
|
|
||||||
children: <QrPanel value={subJsonLink} remark={`${client?.email || ''} — JSON`} />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
links.forEach((link, idx) => {
|
links.forEach((link, idx) => {
|
||||||
const parts = parseLinkParts(link);
|
const parts = parseLinkParts(link);
|
||||||
const meta = parts ? linkMetaText(parts) : '';
|
const meta = parts ? linkMetaText(parts) : '';
|
||||||
@@ -207,7 +191,7 @@ export default function ClientQrModal({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]);
|
}, [subLink, wgConfigText, awgConfigText, links, client?.email, t]);
|
||||||
|
|
||||||
// Expanding the first panel is a render-time adjustment, not a side effect.
|
// Expanding the first panel is a render-time adjustment, not a side effect.
|
||||||
const firstKey = open && items.length > 0 ? items[0].key : null;
|
const firstKey = open && items.length > 0 ? items[0].key : null;
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
SortAscendingOutlined,
|
SortAscendingOutlined,
|
||||||
StopOutlined,
|
StopOutlined,
|
||||||
TagsOutlined,
|
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
UsergroupAddOutlined,
|
UsergroupAddOutlined,
|
||||||
@@ -83,7 +82,6 @@ const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
|
|||||||
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
|
||||||
const FilterDrawer = lazy(() => import('./FilterDrawer'));
|
const FilterDrawer = lazy(() => import('./FilterDrawer'));
|
||||||
const SubLinksModal = lazy(() => import('./SubLinksModal'));
|
const SubLinksModal = lazy(() => import('./SubLinksModal'));
|
||||||
const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
|
|
||||||
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
|
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
|
||||||
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
|
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
|
||||||
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
||||||
@@ -97,45 +95,6 @@ const FILTER_STATE_KEY = 'clientsFilterState';
|
|||||||
const DISABLED_PAGE_SIZE = 200;
|
const DISABLED_PAGE_SIZE = 200;
|
||||||
const DEFAULT_TABLE_PAGE_SIZE = 25;
|
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
|
// 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.
|
// panel with thousands of depleted clients neither ships nor renders them all.
|
||||||
// The trailing chip reports what the popover left out.
|
// The trailing chip reports what the popover left out.
|
||||||
@@ -314,8 +273,6 @@ export default function ClientsPage() {
|
|||||||
bulkAdjust,
|
bulkAdjust,
|
||||||
bulkEnable,
|
bulkEnable,
|
||||||
bulkDisable,
|
bulkDisable,
|
||||||
bulkAddToGroup,
|
|
||||||
bulkRemoveFromGroup,
|
|
||||||
attach,
|
attach,
|
||||||
setExternalLinks,
|
setExternalLinks,
|
||||||
bulkAttach,
|
bulkAttach,
|
||||||
@@ -363,7 +320,6 @@ export default function ClientsPage() {
|
|||||||
const [bulkAddOpen, setBulkAddOpen] = useState(false);
|
const [bulkAddOpen, setBulkAddOpen] = useState(false);
|
||||||
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
|
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
|
||||||
const [subLinksOpen, setSubLinksOpen] = useState(false);
|
const [subLinksOpen, setSubLinksOpen] = useState(false);
|
||||||
const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
|
|
||||||
const [bulkAttachOpen, setBulkAttachOpen] = useState(false);
|
const [bulkAttachOpen, setBulkAttachOpen] = useState(false);
|
||||||
const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
|
const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
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) {
|
function onBulkSetEnable(enable: boolean) {
|
||||||
const emails = [...selectedRowKeys];
|
const emails = [...selectedRowKeys];
|
||||||
if (emails.length === 0) return;
|
if (emails.length === 0) return;
|
||||||
@@ -1382,19 +1317,6 @@ export default function ClientsPage() {
|
|||||||
danger: true,
|
danger: true,
|
||||||
onClick: () => setBulkDetachOpen(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 },
|
{ type: 'divider' as const },
|
||||||
{
|
{
|
||||||
key: 'enable',
|
key: 'enable',
|
||||||
@@ -1912,22 +1834,6 @@ export default function ClientsPage() {
|
|||||||
onOpenChange={setSubLinksOpen}
|
onOpenChange={setSubLinksOpen}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</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}>
|
<LazyMount when={bulkAttachOpen}>
|
||||||
<BulkAttachInboundsModal
|
<BulkAttachInboundsModal
|
||||||
open={bulkAttachOpen}
|
open={bulkAttachOpen}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import type { ClientRecord } from '@/hooks/useClients';
|
|||||||
interface SubSettings {
|
interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SubLinksModalProps {
|
interface SubLinksModalProps {
|
||||||
@@ -26,7 +24,6 @@ interface Row {
|
|||||||
email: string;
|
email: string;
|
||||||
subId: string;
|
subId: string;
|
||||||
link: string;
|
link: string;
|
||||||
jsonLink: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SubLinksModal({
|
export default function SubLinksModal({
|
||||||
@@ -40,7 +37,6 @@ export default function SubLinksModal({
|
|||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
|
|
||||||
const enabled = !!subSettings?.enable && !!subSettings?.subURI;
|
const enabled = !!subSettings?.enable && !!subSettings?.subURI;
|
||||||
const jsonEnabled = !!subSettings?.subJsonEnable && !!subSettings?.subJsonURI;
|
|
||||||
|
|
||||||
const rows = useMemo<Row[]>(() => {
|
const rows = useMemo<Row[]>(() => {
|
||||||
if (!enabled) return [];
|
if (!enabled) return [];
|
||||||
@@ -54,21 +50,12 @@ export default function SubLinksModal({
|
|||||||
email,
|
email,
|
||||||
subId: c.subId,
|
subId: c.subId,
|
||||||
link: subSettings!.subURI + c.subId,
|
link: subSettings!.subURI + c.subId,
|
||||||
jsonLink: jsonEnabled ? subSettings!.subJsonURI + c.subId : '',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [emails, clients, enabled, jsonEnabled, subSettings]);
|
}, [emails, clients, enabled, subSettings]);
|
||||||
|
|
||||||
const allText = useMemo(
|
const allText = useMemo(() => rows.map((r) => `${r.email}\t${r.link}`).join('\n'), [rows]);
|
||||||
() =>
|
|
||||||
rows
|
|
||||||
.map((r) =>
|
|
||||||
jsonEnabled ? `${r.email}\t${r.link}\t${r.jsonLink}` : `${r.email}\t${r.link}`,
|
|
||||||
)
|
|
||||||
.join('\n'),
|
|
||||||
[rows, jsonEnabled],
|
|
||||||
);
|
|
||||||
|
|
||||||
async function copy(text: string, label?: string) {
|
async function copy(text: string, label?: string) {
|
||||||
try {
|
try {
|
||||||
@@ -129,22 +116,6 @@ export default function SubLinksModal({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (jsonEnabled) {
|
|
||||||
columns.splice(2, 0, {
|
|
||||||
title: t('pages.clients.subJsonLinkColumn'),
|
|
||||||
dataIndex: 'jsonLink',
|
|
||||||
key: 'jsonLink',
|
|
||||||
ellipsis: true,
|
|
||||||
render: (link: string) => (
|
|
||||||
<Tooltip title={link} placement="topLeft">
|
|
||||||
<Typography.Text copyable={false} ellipsis>
|
|
||||||
{link}
|
|
||||||
</Typography.Text>
|
|
||||||
</Tooltip>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{messageContextHolder}
|
{messageContextHolder}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MuxForm } from '@/pages/xray/outbounds/transport';
|
import { MuxForm } from '@/lib/xray/forms/outbound/transport';
|
||||||
|
|
||||||
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
||||||
import { serializeOverride } from './helpers';
|
import { serializeOverride } from './helpers';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SockoptForm } from '@/pages/xray/outbounds/transport';
|
import { SockoptForm } from '@/lib/xray/forms/outbound/transport';
|
||||||
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
|
||||||
|
|
||||||
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
genInboundLinks,
|
genInboundLinks,
|
||||||
genWireguardLinks,
|
genWireguardLinks,
|
||||||
preferPublicHost,
|
preferPublicHost,
|
||||||
|
shareHostForNode,
|
||||||
} from '@/lib/xray/inbound-link';
|
} from '@/lib/xray/inbound-link';
|
||||||
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||||
import { Protocols } from '@/schemas/primitives';
|
import { Protocols } from '@/schemas/primitives';
|
||||||
@@ -53,7 +54,6 @@ const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
|
|||||||
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
|
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
|
||||||
const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
|
const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
|
||||||
const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
|
const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
|
||||||
const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
|
|
||||||
|
|
||||||
type RowAction =
|
type RowAction =
|
||||||
| 'edit'
|
| 'edit'
|
||||||
@@ -68,7 +68,6 @@ type RowAction =
|
|||||||
| 'attachClients'
|
| 'attachClients'
|
||||||
| 'attachExisting'
|
| 'attachExisting'
|
||||||
| 'detachClients'
|
| 'detachClients'
|
||||||
| 'addToGroup'
|
|
||||||
| 'clone';
|
| 'clone';
|
||||||
|
|
||||||
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
|
||||||
@@ -165,9 +164,6 @@ export default function InboundsPage() {
|
|||||||
const [detachOpen, setDetachOpen] = useState(false);
|
const [detachOpen, setDetachOpen] = useState(false);
|
||||||
const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
|
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 [cloneOpen, setCloneOpen] = useState(false);
|
||||||
const [cloneSource, setCloneSource] = useState<DBInbound | null>(null);
|
const [cloneSource, setCloneSource] = useState<DBInbound | null>(null);
|
||||||
|
|
||||||
@@ -192,7 +188,10 @@ export default function InboundsPage() {
|
|||||||
const hostOverrideFor = useCallback(
|
const hostOverrideFor = useCallback(
|
||||||
(dbInbound: DBInbound | null) => {
|
(dbInbound: DBInbound | null) => {
|
||||||
if (!dbInbound || dbInbound.nodeId == null) return '';
|
if (!dbInbound || dbInbound.nodeId == null) return '';
|
||||||
return nodesById.get(dbInbound.nodeId)?.address || '';
|
// Agent nodes store the literal placeholder "agent" in address; the
|
||||||
|
// resolved host lives in the node's ipv4/ipv6 (shareHostForNode).
|
||||||
|
// Reading .address raw put "@agent:<port>" into exported links.
|
||||||
|
return shareHostForNode(nodesById.get(dbInbound.nodeId));
|
||||||
},
|
},
|
||||||
[nodesById],
|
[nodesById],
|
||||||
);
|
);
|
||||||
@@ -622,7 +621,6 @@ export default function InboundsPage() {
|
|||||||
'clipboard',
|
'clipboard',
|
||||||
'clone',
|
'clone',
|
||||||
'attachClients',
|
'attachClients',
|
||||||
'addToGroup',
|
|
||||||
];
|
];
|
||||||
let target = dbInbound;
|
let target = dbInbound;
|
||||||
if (hydratingKeys.includes(key)) {
|
if (hydratingKeys.includes(key)) {
|
||||||
@@ -672,10 +670,6 @@ export default function InboundsPage() {
|
|||||||
setDetachSource(target);
|
setDetachSource(target);
|
||||||
setDetachOpen(true);
|
setDetachOpen(true);
|
||||||
break;
|
break;
|
||||||
case 'addToGroup':
|
|
||||||
setGroupSource(target);
|
|
||||||
setGroupOpen(true);
|
|
||||||
break;
|
|
||||||
case 'clone':
|
case 'clone':
|
||||||
confirmClone(target);
|
confirmClone(target);
|
||||||
break;
|
break;
|
||||||
@@ -848,14 +842,6 @@ export default function InboundsPage() {
|
|||||||
source={detachSource}
|
source={detachSource}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
<LazyMount when={groupOpen}>
|
|
||||||
<AddClientsToGroupModal
|
|
||||||
open={groupOpen}
|
|
||||||
onClose={() => setGroupOpen(false)}
|
|
||||||
onAdded={refresh}
|
|
||||||
source={groupSource}
|
|
||||||
/>
|
|
||||||
</LazyMount>
|
|
||||||
<LazyMount when={cloneOpen}>
|
<LazyMount when={cloneOpen}>
|
||||||
<CloneInboundModal
|
<CloneInboundModal
|
||||||
open={cloneOpen}
|
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 { useInboundFallbacks } from './useInboundFallbacks';
|
||||||
import FallbacksCard from './FallbacksCard';
|
import FallbacksCard from './FallbacksCard';
|
||||||
import SniffingTab from './SniffingTab';
|
import SniffingTab from './SniffingTab';
|
||||||
|
import ForwardingTab from './ForwardingTab';
|
||||||
|
|
||||||
import type { DBInbound } from '@/models/dbinbound';
|
import type { DBInbound } from '@/models/dbinbound';
|
||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
@@ -150,6 +151,7 @@ function firstRhfValidationIssue(
|
|||||||
function tabForValidationPath(path: PropertyKey[]): string {
|
function tabForValidationPath(path: PropertyKey[]): string {
|
||||||
if (path[0] === 'settings') return 'protocol';
|
if (path[0] === 'settings') return 'protocol';
|
||||||
if (path[0] === 'sniffing') return 'sniffing';
|
if (path[0] === 'sniffing') return 'sniffing';
|
||||||
|
if (path[0] === 'forwarding') return 'forwarding';
|
||||||
if (path[0] === 'streamSettings') {
|
if (path[0] === 'streamSettings') {
|
||||||
if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings')
|
if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings')
|
||||||
return 'security';
|
return 'security';
|
||||||
@@ -618,7 +620,6 @@ export default function InboundFormModal({
|
|||||||
<FormField name="nodeId" label={t('pages.inbounds.deployTo')}>
|
<FormField name="nodeId" label={t('pages.inbounds.deployTo')}>
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
disabled={mode === 'edit'}
|
|
||||||
placeholder={t('pages.inbounds.localPanel')}
|
placeholder={t('pages.inbounds.localPanel')}
|
||||||
allowClear
|
allowClear
|
||||||
options={selectableNodes.map((n) => ({
|
options={selectableNodes.map((n) => ({
|
||||||
@@ -1079,6 +1080,10 @@ export default function InboundFormModal({
|
|||||||
|
|
||||||
const sniffingTab = <SniffingTab />;
|
const sniffingTab = <SniffingTab />;
|
||||||
|
|
||||||
|
const forwardingTab = (
|
||||||
|
<ForwardingTab currentId={mode === 'edit' && dbInbound ? dbInbound.id : undefined} />
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{messageContextHolder}
|
{messageContextHolder}
|
||||||
@@ -1112,6 +1117,12 @@ export default function InboundFormModal({
|
|||||||
children: basicTab,
|
children: basicTab,
|
||||||
forceRender: true,
|
forceRender: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'forwarding',
|
||||||
|
label: t('pages.inbounds.forwardingTab'),
|
||||||
|
children: forwardingTab,
|
||||||
|
forceRender: true,
|
||||||
|
},
|
||||||
...((
|
...((
|
||||||
[
|
[
|
||||||
Protocols.VLESS,
|
Protocols.VLESS,
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export default function InboundInfoModal({
|
|||||||
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
||||||
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
||||||
const [subLink, setSubLink] = useState('');
|
const [subLink, setSubLink] = useState('');
|
||||||
const [subJsonLink, setSubJsonLink] = useState('');
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [clientIpsArray, setClientIpsArray] = useState<string[]>([]);
|
const [clientIpsArray, setClientIpsArray] = useState<string[]>([]);
|
||||||
const [clientIpsText, setClientIpsText] = useState('');
|
const [clientIpsText, setClientIpsText] = useState('');
|
||||||
@@ -198,12 +197,8 @@ export default function InboundInfoModal({
|
|||||||
|
|
||||||
if (clientSet?.subId) {
|
if (clientSet?.subId) {
|
||||||
setSubLink((subSettings?.subURI || '') + clientSet.subId);
|
setSubLink((subSettings?.subURI || '') + clientSet.subId);
|
||||||
setSubJsonLink(
|
|
||||||
subSettings?.subJsonEnable ? (subSettings?.subJsonURI || '') + clientSet.subId : '',
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
setSubLink('');
|
setSubLink('');
|
||||||
setSubJsonLink('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setClientIpsArray([]);
|
setClientIpsArray([]);
|
||||||
@@ -575,29 +570,6 @@ export default function InboundInfoModal({
|
|||||||
{subLink}
|
{subLink}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{subSettings?.subJsonEnable && subJsonLink && (
|
|
||||||
<div className="link-panel">
|
|
||||||
<div className="link-panel-header">
|
|
||||||
<Tag color="green">JSON</Tag>
|
|
||||||
<Tooltip title={t('copy')}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
aria-label={t('copy')}
|
|
||||||
onClick={() => copyText(subJsonLink, t)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={subJsonLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="link-panel-anchor"
|
|
||||||
>
|
|
||||||
{subJsonLink}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -180,3 +180,11 @@
|
|||||||
padding: 4px;
|
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([]);
|
if (ok) setSelectedRowKeys([]);
|
||||||
}, [onBulkDelete, selectedRowKeys]);
|
}, [onBulkDelete, selectedRowKeys]);
|
||||||
|
|
||||||
|
const inboundsById = useMemo(
|
||||||
|
() => new Map(dbInbounds.map((ib) => [ib.id, ib] as const)),
|
||||||
|
[dbInbounds],
|
||||||
|
);
|
||||||
|
|
||||||
const columns = useInboundColumns({
|
const columns = useInboundColumns({
|
||||||
hasAnyRemark,
|
hasAnyRemark,
|
||||||
hasAnySubSortIndex,
|
hasAnySubSortIndex,
|
||||||
hasActiveNode,
|
hasActiveNode,
|
||||||
nodesById,
|
nodesById,
|
||||||
|
inboundsById,
|
||||||
clientCount,
|
clientCount,
|
||||||
inboundSpeed,
|
inboundSpeed,
|
||||||
subEnable,
|
subEnable,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
BlockOutlined,
|
BlockOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
TagsOutlined,
|
|
||||||
UsergroupAddOutlined,
|
UsergroupAddOutlined,
|
||||||
UsergroupDeleteOutlined,
|
UsergroupDeleteOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
@@ -91,11 +90,6 @@ export function buildRowActionsMenu({
|
|||||||
icon: <UsergroupDeleteOutlined />,
|
icon: <UsergroupDeleteOutlined />,
|
||||||
label: t('pages.inbounds.detachClients'),
|
label: t('pages.inbounds.detachClients'),
|
||||||
});
|
});
|
||||||
items.push({
|
|
||||||
key: 'addToGroup',
|
|
||||||
icon: <TagsOutlined />,
|
|
||||||
label: t('pages.inbounds.addClientsToGroup'),
|
|
||||||
});
|
|
||||||
items.push({ type: 'divider' });
|
items.push({ type: 'divider' });
|
||||||
items.push({
|
items.push({
|
||||||
key: 'delAllClients',
|
key: 'delAllClients',
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
|
|||||||
case 'mtproto':
|
case 'mtproto':
|
||||||
case 'wireguard':
|
case 'wireguard':
|
||||||
case 'amneziawg':
|
case 'amneziawg':
|
||||||
|
case 'socks':
|
||||||
|
case 'mixed':
|
||||||
return true;
|
return true;
|
||||||
case 'shadowsocks':
|
case 'shadowsocks':
|
||||||
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
|
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
|
||||||
@@ -104,3 +106,26 @@ export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
|
|||||||
}
|
}
|
||||||
return false;
|
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: number;
|
||||||
_expiryTime: { valueOf(): number } | null;
|
_expiryTime: { valueOf(): number } | null;
|
||||||
nodeId?: number | null;
|
nodeId?: number | null;
|
||||||
|
tag?: string;
|
||||||
settings: unknown;
|
settings: unknown;
|
||||||
streamSettings: unknown;
|
streamSettings: unknown;
|
||||||
|
forwarding?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientCountEntry {
|
export interface ClientCountEntry {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@/components/utility/speedTagStyle';
|
} from '@/components/utility/speedTagStyle';
|
||||||
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
|
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
|
||||||
import {
|
import {
|
||||||
|
forwardingLandingLabel,
|
||||||
readStreamHints,
|
readStreamHints,
|
||||||
networkLabel,
|
networkLabel,
|
||||||
networkL4,
|
networkL4,
|
||||||
@@ -31,6 +32,7 @@ interface UseInboundColumnsParams {
|
|||||||
hasAnySubSortIndex: boolean;
|
hasAnySubSortIndex: boolean;
|
||||||
hasActiveNode: boolean;
|
hasActiveNode: boolean;
|
||||||
nodesById: Map<number, NodeRecord>;
|
nodesById: Map<number, NodeRecord>;
|
||||||
|
inboundsById: Map<number, DBInboundRecord>;
|
||||||
clientCount: Record<number, ClientCountEntry>;
|
clientCount: Record<number, ClientCountEntry>;
|
||||||
inboundSpeed: Record<number, InboundSpeedEntry>;
|
inboundSpeed: Record<number, InboundSpeedEntry>;
|
||||||
subEnable: boolean;
|
subEnable: boolean;
|
||||||
@@ -45,6 +47,7 @@ export function useInboundColumns({
|
|||||||
hasAnySubSortIndex,
|
hasAnySubSortIndex,
|
||||||
hasActiveNode,
|
hasActiveNode,
|
||||||
nodesById,
|
nodesById,
|
||||||
|
inboundsById,
|
||||||
clientCount,
|
clientCount,
|
||||||
inboundSpeed,
|
inboundSpeed,
|
||||||
subEnable,
|
subEnable,
|
||||||
@@ -257,6 +260,17 @@ export function useInboundColumns({
|
|||||||
</Tag>,
|
</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>;
|
return <div className="protocol-tags">{tags}</div>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -453,6 +467,7 @@ export function useInboundColumns({
|
|||||||
hasAnySubSortIndex,
|
hasAnySubSortIndex,
|
||||||
hasActiveNode,
|
hasActiveNode,
|
||||||
nodesById,
|
nodesById,
|
||||||
|
inboundsById,
|
||||||
clientCount,
|
clientCount,
|
||||||
inboundSpeed,
|
inboundSpeed,
|
||||||
subEnable,
|
subEnable,
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export default function QrCodeModal({
|
|||||||
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
|
||||||
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
|
||||||
const [subLink, setSubLink] = useState('');
|
const [subLink, setSubLink] = useState('');
|
||||||
const [subJsonLink, setSubJsonLink] = useState('');
|
|
||||||
const [activeKey, setActiveKey] = useState<string[]>([]);
|
const [activeKey, setActiveKey] = useState<string[]>([]);
|
||||||
|
|
||||||
// Building the links is a pure function of the props, so it runs during
|
// Building the links is a pure function of the props, so it runs during
|
||||||
@@ -145,13 +144,10 @@ export default function QrCodeModal({
|
|||||||
|
|
||||||
const subId = client?.subId;
|
const subId = client?.subId;
|
||||||
let nextSub = '';
|
let nextSub = '';
|
||||||
let nextSubJson = '';
|
|
||||||
if (subSettings?.enable && subId) {
|
if (subSettings?.enable && subId) {
|
||||||
nextSub = (subSettings.subURI || '') + subId;
|
nextSub = (subSettings.subURI || '') + subId;
|
||||||
nextSubJson = subSettings.subJsonEnable ? (subSettings.subJsonURI || '') + subId : '';
|
|
||||||
}
|
}
|
||||||
setSubLink(nextSub);
|
setSubLink(nextSub);
|
||||||
setSubJsonLink(nextSubJson);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const qrItems = useMemo<QrItem[]>(() => {
|
const qrItems = useMemo<QrItem[]>(() => {
|
||||||
@@ -159,13 +155,6 @@ export default function QrCodeModal({
|
|||||||
if (subLink) {
|
if (subLink) {
|
||||||
items.push({ key: 'sub', header: t('subscription.title'), value: subLink });
|
items.push({ key: 'sub', header: t('subscription.title'), value: subLink });
|
||||||
}
|
}
|
||||||
if (subJsonLink) {
|
|
||||||
items.push({
|
|
||||||
key: 'sub-json',
|
|
||||||
header: `${t('subscription.title')} (JSON)`,
|
|
||||||
value: subJsonLink,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
links.forEach((link, idx) => {
|
links.forEach((link, idx) => {
|
||||||
items.push({ key: `l${idx}`, header: link.remark || `Link ${idx + 1}`, value: link.link });
|
items.push({ key: `l${idx}`, header: link.remark || `Link ${idx + 1}`, value: link.link });
|
||||||
});
|
});
|
||||||
@@ -202,16 +191,7 @@ export default function QrCodeModal({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return items;
|
return items;
|
||||||
}, [
|
}, [subLink, links, wireguardConfigs, wireguardLinks, amneziawgConfigs, amneziawgLinks, t]);
|
||||||
subLink,
|
|
||||||
subJsonLink,
|
|
||||||
links,
|
|
||||||
wireguardConfigs,
|
|
||||||
wireguardLinks,
|
|
||||||
amneziawgConfigs,
|
|
||||||
amneziawgLinks,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const collapseItems: CollapseProps['items'] = useMemo(
|
const collapseItems: CollapseProps['items'] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export interface SubSettings {
|
|||||||
enable: boolean;
|
enable: boolean;
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subJsonURI: string;
|
|
||||||
subJsonEnable: boolean;
|
|
||||||
// Configured public host (Sub Domain, else Web Domain) used as the share/QR
|
// Configured public host (Sub Domain, else Web Domain) used as the share/QR
|
||||||
// link host when the panel is reached on a loopback address. Empty if neither
|
// link host when the panel is reached on a loopback address. Empty if neither
|
||||||
// is set.
|
// is set.
|
||||||
@@ -179,23 +177,19 @@ export function useInbounds() {
|
|||||||
const tgBotEnable = !!defaults.tgBotEnable;
|
const tgBotEnable = !!defaults.tgBotEnable;
|
||||||
const ipLimitEnable = !!defaults.ipLimitEnable;
|
const ipLimitEnable = !!defaults.ipLimitEnable;
|
||||||
const pageSize = defaults.pageSize ?? 0;
|
const pageSize = defaults.pageSize ?? 0;
|
||||||
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
|
const datepicker = (defaults.datepicker as 'gregorian') || 'gregorian';
|
||||||
|
|
||||||
const subSettings: SubSettings = useMemo(
|
const subSettings: SubSettings = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
enable: !!defaults.subEnable,
|
enable: !!defaults.subEnable,
|
||||||
subTitle: defaults.subTitle || '',
|
subTitle: defaults.subTitle || '',
|
||||||
subURI: defaults.subURI || '',
|
subURI: defaults.subURI || '',
|
||||||
subJsonURI: defaults.subJsonURI || '',
|
|
||||||
subJsonEnable: !!defaults.subJsonEnable,
|
|
||||||
publicHost: defaults.subDomain || defaults.webDomain || '',
|
publicHost: defaults.subDomain || defaults.webDomain || '',
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
defaults.subEnable,
|
defaults.subEnable,
|
||||||
defaults.subTitle,
|
defaults.subTitle,
|
||||||
defaults.subURI,
|
defaults.subURI,
|
||||||
defaults.subJsonURI,
|
|
||||||
defaults.subJsonEnable,
|
|
||||||
defaults.subDomain,
|
defaults.subDomain,
|
||||||
defaults.webDomain,
|
defaults.webDomain,
|
||||||
],
|
],
|
||||||
@@ -392,16 +386,13 @@ export function useInbounds() {
|
|||||||
// Invalidate at the inbounds root so both `slim` (this page's list)
|
// Invalidate at the inbounds root so both `slim` (this page's list)
|
||||||
// and `options` (the Clients page's inbound picker) refetch. Without
|
// and `options` (the Clients page's inbound picker) refetch. Without
|
||||||
// the options bucket, a freshly-created inbound stays invisible in
|
// the options bucket, a freshly-created inbound stays invisible in
|
||||||
// the client add/edit modal until a full page reload. The xray config
|
// the client add/edit modal until a full page reload.
|
||||||
// response carries inboundTags for the routing-rule tag picker, so it
|
|
||||||
// needs invalidating too or that list stays stale until a hard refresh.
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
|
||||||
]);
|
]);
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -38,53 +38,6 @@
|
|||||||
flex-wrap: wrap;
|
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 {
|
.ov-panel-version {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -95,8 +48,6 @@
|
|||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ov-state-version:hover,
|
|
||||||
.ov-state-version:focus-visible,
|
|
||||||
.ov-panel-version:hover,
|
.ov-panel-version:hover,
|
||||||
.ov-panel-version:focus-visible {
|
.ov-panel-version:focus-visible {
|
||||||
color: var(--ant-color-primary);
|
color: var(--ant-color-primary);
|
||||||
@@ -112,11 +63,6 @@
|
|||||||
margin-inline-end: 0;
|
margin-inline-end: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ov-error-detail {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ov-bar-actions {
|
.ov-bar-actions {
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -32,12 +32,7 @@ import { mean, peak, useOverviewHistory } from './useOverviewHistory';
|
|||||||
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
||||||
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
||||||
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
||||||
const LogModal = lazy(() => import('./LogModal'));
|
|
||||||
const BackupModal = lazy(() => import('./BackupModal'));
|
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'));
|
|
||||||
import './IndexPage.css';
|
import './IndexPage.css';
|
||||||
|
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
@@ -50,7 +45,8 @@ export default function IndexPage() {
|
|||||||
setMessageInstance(messageApi);
|
setMessageInstance(messageApi);
|
||||||
}, [messageApi]);
|
}, [messageApi]);
|
||||||
|
|
||||||
const [accessLogEnable, setAccessLogEnable] = useState(false);
|
const history = useOverviewHistory(status, fetched && !fetchError);
|
||||||
|
|
||||||
const [devChannelEnable, setDevChannelEnable] = useState(false);
|
const [devChannelEnable, setDevChannelEnable] = useState(false);
|
||||||
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
||||||
currentVersion: '',
|
currentVersion: '',
|
||||||
@@ -61,29 +57,21 @@ export default function IndexPage() {
|
|||||||
const basePath = window.X_UI_BASE_PATH || '';
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
|
||||||
const [showIp, setShowIp] = useState(false);
|
const [showIp, setShowIp] = useState(false);
|
||||||
const [logsOpen, setLogsOpen] = useState(false);
|
|
||||||
const [backupOpen, setBackupOpen] = useState(false);
|
const [backupOpen, setBackupOpen] = useState(false);
|
||||||
const [panelUpdateOpen, setPanelUpdateOpen] = 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 [configTextOpen, setConfigTextOpen] = useState(false);
|
const [configTextOpen, setConfigTextOpen] = useState(false);
|
||||||
const [configText, setConfigText] = useState('');
|
const [configText, setConfigText] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||||
|
|
||||||
const history = useOverviewHistory(status, fetched && !fetchError);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
|
HttpUtil.post<{ devChannelEnable?: boolean }>('/panel/api/setting/defaultSettings').then(
|
||||||
'/panel/api/setting/defaultSettings',
|
(msg) => {
|
||||||
).then((msg) => {
|
if (msg?.success && msg.obj) {
|
||||||
if (msg?.success && msg.obj) {
|
setDevChannelEnable(!!msg.obj.devChannelEnable);
|
||||||
setAccessLogEnable(!!msg.obj.accessLogEnable);
|
}
|
||||||
setDevChannelEnable(!!msg.obj.devChannelEnable);
|
},
|
||||||
}
|
);
|
||||||
});
|
|
||||||
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||||
});
|
});
|
||||||
@@ -99,16 +87,6 @@ export default function IndexPage() {
|
|||||||
if (tip) setLoadingTip(tip);
|
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) {
|
async function handleChannelChange(dev: boolean) {
|
||||||
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
|
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
|
||||||
if (!res?.success) return;
|
if (!res?.success) return;
|
||||||
@@ -192,21 +170,21 @@ export default function IndexPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="ov-page">
|
<div className="ov-page">
|
||||||
<OverviewActionBar
|
<OverviewActionBar
|
||||||
status={status}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
accessLogEnable={accessLogEnable}
|
|
||||||
panelVersion={displayVersion}
|
panelVersion={displayVersion}
|
||||||
latestVersion={panelUpdateInfo.latestVersion}
|
latestVersion={
|
||||||
updateAvailable={panelUpdateInfo.updateAvailable}
|
panelUpdateInfo.channel === 'dev'
|
||||||
onStopXray={stopXray}
|
? panelUpdateInfo.latestVersion
|
||||||
onRestartXray={restartXray}
|
: panelUpdateInfo.displayLatestVersion || panelUpdateInfo.latestVersion
|
||||||
onOpenLogs={() => setLogsOpen(true)}
|
}
|
||||||
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
updateAvailable={
|
||||||
onOpenAmneziaWGLogs={() => setAmneziawgLogsOpen(true)}
|
panelUpdateInfo.channel === 'dev'
|
||||||
|
? panelUpdateInfo.updateAvailable
|
||||||
|
: (panelUpdateInfo.displayUpdateAvailable ??
|
||||||
|
panelUpdateInfo.updateAvailable)
|
||||||
|
}
|
||||||
onOpenConfig={openConfig}
|
onOpenConfig={openConfig}
|
||||||
onOpenBackup={() => setBackupOpen(true)}
|
onOpenBackup={() => setBackupOpen(true)}
|
||||||
onOpenSystemHistory={() => setSysHistoryOpen(true)}
|
|
||||||
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
|
|
||||||
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -304,9 +282,6 @@ export default function IndexPage() {
|
|||||||
onBusy={setBusy}
|
onBusy={setBusy}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
<LazyMount when={logsOpen}>
|
|
||||||
<LogModal open={logsOpen} onClose={() => setLogsOpen(false)} />
|
|
||||||
</LazyMount>
|
|
||||||
<LazyMount when={backupOpen}>
|
<LazyMount when={backupOpen}>
|
||||||
<BackupModal
|
<BackupModal
|
||||||
open={backupOpen}
|
open={backupOpen}
|
||||||
@@ -315,22 +290,6 @@ export default function IndexPage() {
|
|||||||
onBusy={setBusy}
|
onBusy={setBusy}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</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={configTextOpen}>
|
<LazyMount when={configTextOpen}>
|
||||||
<Modal
|
<Modal
|
||||||
open={configTextOpen}
|
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,39 +1,17 @@
|
|||||||
import { Fragment } from 'react';
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Tag, Tooltip } from 'antd';
|
import { Button, Tag, Tooltip } from 'antd';
|
||||||
import {
|
import { CloudDownloadOutlined, CloudServerOutlined, ControlOutlined } from '@ant-design/icons';
|
||||||
ApiOutlined,
|
|
||||||
ArrowUpOutlined,
|
|
||||||
AreaChartOutlined,
|
|
||||||
BarsOutlined,
|
|
||||||
CloudDownloadOutlined,
|
|
||||||
CloudServerOutlined,
|
|
||||||
ControlOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
PoweroffOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { formatPanelVersion } from '@/lib/panel-version';
|
import { formatPanelVersion } from '@/lib/panel-version';
|
||||||
import type { Status } from '@/models/status';
|
|
||||||
|
|
||||||
interface OverviewActionBarProps {
|
interface OverviewActionBarProps {
|
||||||
status: Status;
|
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
accessLogEnable: boolean;
|
|
||||||
panelVersion: string;
|
panelVersion: string;
|
||||||
latestVersion: string;
|
latestVersion: string;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
onStopXray: () => void;
|
|
||||||
onRestartXray: () => void;
|
|
||||||
onOpenLogs: () => void;
|
|
||||||
onOpenXrayLogs: () => void;
|
|
||||||
onOpenAmneziaWGLogs: () => void;
|
|
||||||
onOpenConfig: () => void;
|
onOpenConfig: () => void;
|
||||||
onOpenBackup: () => void;
|
onOpenBackup: () => void;
|
||||||
onOpenSystemHistory: () => void;
|
|
||||||
onOpenXrayMetrics: () => void;
|
|
||||||
onOpenPanelUpdate: () => void;
|
onOpenPanelUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,120 +23,35 @@ interface BarAction {
|
|||||||
primary?: boolean;
|
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({
|
export default function OverviewActionBar({
|
||||||
status,
|
|
||||||
isMobile,
|
isMobile,
|
||||||
accessLogEnable,
|
|
||||||
panelVersion,
|
panelVersion,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
updateAvailable,
|
updateAvailable,
|
||||||
onStopXray,
|
|
||||||
onRestartXray,
|
|
||||||
onOpenLogs,
|
|
||||||
onOpenXrayLogs,
|
|
||||||
onOpenAmneziaWGLogs,
|
|
||||||
onOpenConfig,
|
onOpenConfig,
|
||||||
onOpenBackup,
|
onOpenBackup,
|
||||||
onOpenSystemHistory,
|
|
||||||
onOpenXrayMetrics,
|
|
||||||
onOpenPanelUpdate,
|
onOpenPanelUpdate,
|
||||||
}: OverviewActionBarProps) {
|
}: OverviewActionBarProps) {
|
||||||
const { t } = useTranslation();
|
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 size = isMobile ? ('small' as const) : ('middle' as const);
|
||||||
|
|
||||||
const actionGroups: BarAction[][] = [
|
const actions: BarAction[] = [
|
||||||
[
|
{
|
||||||
{
|
key: 'config',
|
||||||
key: 'restart',
|
icon: <ControlOutlined />,
|
||||||
icon: <ReloadOutlined />,
|
text: t('pages.index.config'),
|
||||||
text: t('pages.index.restartXray'),
|
onClick: onOpenConfig,
|
||||||
onClick: onRestartXray,
|
},
|
||||||
primary: true,
|
{
|
||||||
},
|
key: 'backup',
|
||||||
{
|
icon: <CloudServerOutlined />,
|
||||||
key: 'stop',
|
text: t('pages.index.backupTitle'),
|
||||||
icon: <PoweroffOutlined />,
|
onClick: onOpenBackup,
|
||||||
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 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 && <span className="ov-state-version">{`v${status.xray.version}`}</span>}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ov-bar">
|
<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 ? (
|
{updateAvailable ? (
|
||||||
<Tag
|
<Tag
|
||||||
className="ov-update-tag"
|
className="ov-update-tag"
|
||||||
@@ -177,24 +70,19 @@ export default function OverviewActionBar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="ov-bar-actions">
|
<div className="ov-bar-actions">
|
||||||
{actionGroups.map((group, groupIndex) => (
|
{actions.map((action) => (
|
||||||
<Fragment key={group[0].key}>
|
<Button
|
||||||
{groupIndex > 0 && <span className="ov-bar-sep" />}
|
key={action.key}
|
||||||
{group.map((action) => (
|
type={action.primary ? undefined : 'text'}
|
||||||
<Button
|
color={action.primary ? 'primary' : undefined}
|
||||||
key={action.key}
|
variant={action.primary ? 'outlined' : undefined}
|
||||||
type={action.primary ? undefined : 'text'}
|
size={size}
|
||||||
color={action.primary ? 'primary' : undefined}
|
icon={action.icon}
|
||||||
variant={action.primary ? 'outlined' : undefined}
|
aria-label={action.text}
|
||||||
size={size}
|
onClick={action.onClick}
|
||||||
icon={action.icon}
|
>
|
||||||
aria-label={action.text}
|
{isMobile ? undefined : action.text}
|
||||||
onClick={action.onClick}
|
</Button>
|
||||||
>
|
|
||||||
{isMobile ? undefined : action.text}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export interface PanelUpdateInfo {
|
|||||||
currentCommit?: string;
|
currentCommit?: string;
|
||||||
latestCommit?: string;
|
latestCommit?: string;
|
||||||
updateAvailable: boolean;
|
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 {
|
interface BusyEvent {
|
||||||
@@ -47,6 +52,13 @@ export default function PanelUpdateModal({
|
|||||||
|
|
||||||
const isDev = info.channel === 'dev';
|
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> {
|
async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> {
|
||||||
await PromiseUtil.sleep(5000);
|
await PromiseUtil.sleep(5000);
|
||||||
const deadline = Date.now() + 90_000;
|
const deadline = Date.now() + 90_000;
|
||||||
@@ -85,13 +97,14 @@ export default function PanelUpdateModal({
|
|||||||
title: t('pages.index.panelUpdateDialog'),
|
title: t('pages.index.panelUpdateDialog'),
|
||||||
content: t('pages.index.panelUpdateDialogDesc').replace(
|
content: t('pages.index.panelUpdateDialogDesc').replace(
|
||||||
'#version#',
|
'#version#',
|
||||||
info.latestVersion || '',
|
isDev ? info.latestVersion || '' : displayLatest || '',
|
||||||
),
|
),
|
||||||
okText: t('confirm'),
|
okText: t('confirm'),
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const baseTip = t('pages.index.dontRefresh');
|
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();
|
onClose();
|
||||||
onBusy({ busy: true, tip });
|
onBusy({ busy: true, tip });
|
||||||
const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel');
|
const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel');
|
||||||
@@ -128,7 +141,7 @@ export default function PanelUpdateModal({
|
|||||||
<>
|
<>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<Modal open={open} title={t('pages.index.updatePanel')} footer={null} onCancel={onClose}>
|
<Modal open={open} title={t('pages.index.updatePanel')} footer={null} onCancel={onClose}>
|
||||||
{info.updateAvailable && (
|
{masterUpdateAvailable && (
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
className="mb-12"
|
className="mb-12"
|
||||||
@@ -166,12 +179,14 @@ export default function PanelUpdateModal({
|
|||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{info.updateAvailable ? (
|
{masterUpdateAvailable ? (
|
||||||
<div className="version-list-item">
|
<div className="version-list-item">
|
||||||
<span>
|
<span>
|
||||||
{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}
|
{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}
|
||||||
</span>
|
</span>
|
||||||
<Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag>
|
<Tag color="purple">
|
||||||
|
{(isDev ? info.latestCommit : formatPanelVersion(displayLatest)) || '-'}
|
||||||
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="version-list-item">
|
<div className="version-list-item">
|
||||||
@@ -184,7 +199,7 @@ export default function PanelUpdateModal({
|
|||||||
<div className="actions-row">
|
<div className="actions-row">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
disabled={!info.updateAvailable}
|
disabled={!masterUpdateAvailable}
|
||||||
onClick={updatePanel}
|
onClick={updatePanel}
|
||||||
icon={<CloudDownloadOutlined />}
|
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 />
|
<ClockCircleOutlined />
|
||||||
{t('pages.index.uptime')}
|
{t('pages.index.uptime')}
|
||||||
</div>
|
</div>
|
||||||
<div className="ov-strip-split">
|
{/* The lite master runs no local xray core, so the uptime card
|
||||||
<div>
|
shows the OS uptime only (the Xray half always read 0s). */}
|
||||||
<div className="ov-strip-sub">Xray</div>
|
<div>
|
||||||
<div className="ov-strip-value">
|
<div className="ov-strip-sub">OS</div>
|
||||||
{TimeFormatter.formatSecond(status.appStats.uptime)}
|
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user