Author SHA1 Message Date
Muse 17be1ad5ae 修复二开 agent 流量增量丢失: 基线改上报确认后推进并落盘持久化
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI for Docker / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
- xray 新增快照/确认接口 GetTrafficSnapshot/CommitTrafficSnapshot/
  RestoreTrafficBaseline, 与面板 GetTraffic 的 StatsLastValues 完全隔离
- agent 改走快照: 仅主控 HTTP 200 后才推进基线, 失败轮增量由累计
  计数做差在下轮自动补齐; 已确认基线原子落盘, agent 重启恢复续差
- 核心重启计数归零的从零规则保留; Onlines 仍每轮新鲜采集直报
- 新增 11 个测试覆盖断网恢复/连续失败补齐/重启恢复/计数归零/
  面板语义隔离, xray 与 agent 两包全绿
2026-10-21 01:24:14 +08:00
Muse a654a96c38 精简版第四轮补裁(P4): 页面层摘除 + Xray更新端点下线 + 本机核心监管下线
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
页面层(用户真机截图发现前两轮漏裁):
- 设置: 摘除 Telegram机器人/邮件菜单与页面, 及页内残留入口(通知页/外部流量/LDAP/2FA设置页)
- Xray配置: 摘除负载均衡/DNS/高级配置菜单与页面; 基础配置(出站+路由)完好
- 总览: 摘除 Xray更新弹窗(VersionModal+GeodataSection)全部入口, 版本号改为静态展示
- 13个locale同步摘除461个已删功能专属翻译键(逐键核查前后端引用为零; i18n-dead-keys测试全绿)

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

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

注: TestGetXrayConfig_EnabledClientsStillEmitted 在基线945b3a5同样失败(既有问题, 非本轮引入)
2026-10-20 23:39:38 +08:00
868 changed files with 66270 additions and 50457 deletions
-3
View File
@@ -19,9 +19,6 @@ backup/
bin/
x-ui/
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/
node_modules/
+5 -8
View File
@@ -24,16 +24,13 @@
- 安全设置:双重验证(2FA,用户不用)
- 邮件模块(SMTP 发送:到期/流量提醒等,用户不用)
- 分组功能(客户端归类标签 + 按组批量操作/聚合订阅;用户从未用过,裁功能不影响已建客户端与订阅)
- 本地核心实测三件套(P9 第 4 条,用户 2026-10-21 晚改口):出站测速(临时 xray 实例批量探测 `testOutbound(s)` 接口 + 前端测试按钮/模式切换/结果弹层)、出口探测(同一动作的 egress IP/国家展示)、重启/停止核心接口(`restartXrayService`/`stopXrayService`,与首页已摘按钮同族);出站与路由的模板编辑(增删改存)保留不动
- 本地核心整条路径(P9 第 4b 条):进程管理/状态机、buildXrayConfig 本地分支与 local-only 注入(panel/node egress 桥、订阅出站合并、mtproto/amneziawg 桥)、本地核心专属接口(balancer 状态/override、routeTest、getXrayResult)与前端 RouteTester、本地核心三个 job(流量采集/存活看门狗/客户端 IP 限制)已整条删除;配置构建只剩 agent 一条线(模板 + 节点入站 + egress + 转发编译)。MTProto/AmneziaWG 随之退役:新建/编辑直接拒绝,存量行保留在库中可删除、不再被服务。金标准验证:同一种子库删除前后三台 agent 的生成配置逐字一致
## 范围确认(2026-10-21 用户拍板:四项全部保留,不裁)
## 待用户确认
- 协议范围:保留全部协议模板(VLESS、SS2022、VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等),不收窄
- IP 检查 / 测速类后台任务:保留 → **2026-10-21 晚用户改口推翻本项**:出站测速 + 出口探测(同一动作,测的是主控本地网络位置;精简主控不跑本机核心、无本地出站,测速结果无意义)连同重启/停止核心控件与接口,已于 P9 第 4 条摘除。其余三项(协议范围 / ACME / 多用户)保留不变,不要再翻出来问。
- 面板内置 ACME 签证书:保留
- 多用户功能:保留
- 用户理由:这四块以静态代码为主,裁掉省不了多少内存。此节关闭,后续轮次不要再翻出来问。
- 协议范围:是否只留 VLESS + SS2022,裁其余协议模板(VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等)——建议裁
- IP 检查 / 测速类后台任务——建议裁
- 面板内置 ACME 签证书(走 CF Tunnel,不直签)——建议裁
- 多用户功能(单管理员使用)——建议裁
## 修复(非裁剪,已定)
+2 -2
View File
@@ -45,8 +45,8 @@ cp geoip.dat geosite.dat /usr/local/x-ui/bin/
```bash
mkdir -p /usr/local/x-ui/bin /etc/x-ui
# 1. 放同一个二进制(与主控同版本),在节点上改名为 xui-agent(与主控进程区分、避免同机覆盖)
cp x-ui-linux-amd64 /usr/local/x-ui/xui-agent && chmod +x /usr/local/x-ui/xui-agent
# 1. 放同一个 x-ui 二进制(与主控同版本)
cp x-ui-linux-amd64 /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,需手动放置)
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
+2 -2
View File
@@ -37,8 +37,8 @@ curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
```bash
mkdir -p /usr/local/x-ui/bin /etc/x-ui
# 1. 放 x-ui 二进制(agent 补丁版,与主控同版本),在节点上改名为 xui-agent(与主控进程区分)
cp x-ui /usr/local/x-ui/xui-agent && chmod +x /usr/local/x-ui/xui-agent
# 1. 放 x-ui 二进制(agent 补丁版,与主控同版本)
cp x-ui /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,手动放置)
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 (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
<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 />
</>
);
}
@@ -69,7 +69,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
<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 />
</>
);
}
@@ -69,7 +69,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
<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 />
</>
);
}
@@ -66,7 +66,7 @@ export default function Layout(props) {
return (
<>
{props.children}
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"}]} showTitle />
<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 />
</>
);
}
+3 -2
View File
@@ -10,7 +10,7 @@
},
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "npm run gen:api && vite build",
"preview": "vite preview",
"lint": "oxlint src tools",
"lint:fix": "oxlint --fix src tools",
@@ -22,7 +22,8 @@
"test:watch": "vitest",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"gen": "npm run gen:zod",
"gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen",
"prepare": "cd .. && husky frontend/.husky || true"
},
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
#!/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;
-4
View File
@@ -127,10 +127,6 @@ async function performFetch(
if (data instanceof FormData) {
body = data;
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)) {
const declaredType = (headers.get('Content-Type') || '').toLowerCase();
if (declaredType.startsWith('application/json')) {
+111
View File
@@ -0,0 +1,111 @@
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 : [];
},
});
}
+23 -41
View File
@@ -1,58 +1,36 @@
import { useQuery } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
// 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
: [],
};
}
import { fetchXrayConfig } from '@/hooks/useXraySetting';
// Available outbound (and balancer-eligible) tags the user can route an mtproto
// inbound's Telegram traffic to. Shares one cached directory query so opening
// the inbound form costs no extra request when another picker already
// inbound's Telegram traffic to. Shares the cached xray config query so opening
// the inbound form costs no extra request when the Xray page was already
// visited; `select` derives just the tag list without disturbing other readers.
export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
const excludeBlackhole = opts?.excludeBlackhole ?? false;
return useQuery({
queryKey: keys.server.outboundDirectory(),
queryFn: fetchOutboundDirectory,
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
staleTime: Infinity,
select: (data): string[] => {
const tags = new Set<string>();
for (const ob of data.outbounds) {
for (const o of data?.xraySetting?.outbounds ?? []) {
const ob = o as { tag?: string; protocol?: string } | null;
if (!ob?.tag) continue;
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
tags.add(ob.tag);
}
for (const t of data.subscriptionOutboundTags) {
for (const t of data?.subscriptionOutboundTags ?? []) {
if (t) tags.add(t);
}
// Balancers are valid routing targets too — injectMtprotoEgress emits a
// balancerTag rule when the chosen tag names a balancer.
for (const tag of data.balancers) {
if (tag) tags.add(tag);
const balancers = (
data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
)?.balancers;
for (const b of balancers ?? []) {
if (b?.tag) tags.add(b.tag);
}
return [...tags];
},
@@ -70,22 +48,26 @@ export interface OutboundTagGroups {
export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
const excludeBlackhole = opts?.excludeBlackhole ?? false;
return useQuery({
queryKey: keys.server.outboundDirectory(),
queryFn: fetchOutboundDirectory,
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
staleTime: Infinity,
select: (data): OutboundTagGroups => {
const outbounds = new Set<string>();
for (const ob of data.outbounds) {
for (const o of data?.xraySetting?.outbounds ?? []) {
const ob = o as { tag?: string; protocol?: string } | null;
if (!ob?.tag) continue;
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
outbounds.add(ob.tag);
}
for (const t of data.subscriptionOutboundTags) {
for (const t of data?.subscriptionOutboundTags ?? []) {
if (t) outbounds.add(t);
}
const balancers: string[] = [];
for (const tag of data.balancers) {
if (tag && !outbounds.has(tag)) balancers.push(tag);
const bal = (
data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined
)?.balancers;
for (const b of bal ?? []) {
if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag);
}
return { outbounds: [...outbounds], balancers };
},
@@ -0,0 +1,41 @@
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),
};
}
@@ -0,0 +1,31 @@
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,
};
}
+18 -1
View File
@@ -2,7 +2,6 @@ export const keys = {
server: {
status: () => ['server', 'status'] as const,
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
outboundDirectory: () => ['server', 'outboundDirectory'] as const,
},
nodes: {
root: () => ['nodes'] as const,
@@ -14,6 +13,10 @@ export const keys = {
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
tags: () => ['hosts', 'tags'] as const,
},
subBalancers: {
root: () => ['sub-balancers'] as const,
list: () => ['sub-balancers', 'list'] as const,
},
settings: {
root: () => ['settings'] as const,
all: () => ['settings', 'all'] as const,
@@ -33,5 +36,19 @@ export const keys = {
onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
activeInbounds: () => ['clients', 'activeInbounds'] as const,
lastOnline: () => ['clients', 'lastOnline'] as const,
groups: () => ['clients', 'groups'] as const,
},
xray: {
root: () => ['xray'] as const,
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;
+7
View File
@@ -30,6 +30,11 @@ export function useWebSocketBridge() {
}, 200);
};
const onOutbounds: Handler = (payload) => {
if (!Array.isArray(payload)) return;
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
};
const onNodes: Handler = (payload) => {
if (!Array.isArray(payload)) return;
queryClient.setQueryData(keys.nodes.list(), payload);
@@ -41,12 +46,14 @@ export function useWebSocketBridge() {
};
client.on('invalidate', onInvalidate);
client.on('outbounds', onOutbounds);
client.on('nodes', onNodes);
client.on('inbounds', onInbounds);
client.connect();
return () => {
client.off('invalidate', onInvalidate);
client.off('outbounds', onOutbounds);
client.off('nodes', onNodes);
client.off('inbounds', onInbounds);
if (invalidateTimer != null) {
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Typography } from 'antd';
import dayjs from 'dayjs';
@@ -25,6 +25,15 @@ 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 = {
title: 'Form/DateTimePicker',
component: DateTimePicker,
@@ -34,7 +43,7 @@ const meta = {
docs: {
description: {
component:
'Date/time picker used for client and inbound expiry dates, rendered with the AntD DatePicker.',
'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.',
},
},
},
@@ -81,3 +90,9 @@ export const DateOnly: Story = {
placeholder: 'Start date',
},
};
export const JalaliCalendar: Story = {
args: { value: null, onChange: () => undefined },
parameters: { docs: { disable: true } },
render: () => <JalaliDemo />,
};
@@ -1,5 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseCircleFilled } from '@ant-design/icons';
import { DatePicker } from 'antd';
import 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 {
value: Dayjs | null;
@@ -10,6 +19,33 @@ interface DateTimePickerProps {
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({
value,
onChange,
@@ -18,6 +54,83 @@ export default function DateTimePicker({
placeholder = '',
disabled = false,
}: 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 (
<DatePicker
value={value}
@@ -0,0 +1,221 @@
.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;
}
@@ -0,0 +1,864 @@
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: '' },
};
@@ -0,0 +1,473 @@
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');
}
@@ -0,0 +1,296 @@
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();
},
};
@@ -0,0 +1,140 @@
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];
}
+4
View File
@@ -0,0 +1,4 @@
export { default as GeoBrowserModal } from './GeoBrowserModal';
export type { GeoBrowserModalProps } from './GeoBrowserModal';
export { default as GeoTokenInput } from './GeoTokenInput';
export type { GeoTokenInputProps } from './GeoTokenInput';
@@ -0,0 +1,90 @@
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,
})
}
/>
),
};
@@ -0,0 +1,138 @@
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 = {
args: {
label: 'pages.settings.eventNodeDown',
label: 'pages.settings.eventXrayCrash',
checked: true,
onToggle: () => undefined,
},
@@ -1,7 +1,7 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { InputNumber } from 'antd';
import { DashboardOutlined, DesktopOutlined } from '@ant-design/icons';
import { CloudServerOutlined, DashboardOutlined } from '@ant-design/icons';
import { AllSetting } from '@/models/setting';
import { NotificationGroup } from './NotificationGroup';
@@ -46,12 +46,12 @@ const systemGroup: NotificationGroupConfig = {
],
};
const nodeGroup: NotificationGroupConfig = {
icon: <DesktopOutlined />,
title: 'eventGroupNode',
const outboundGroup: NotificationGroupConfig = {
icon: <CloudServerOutlined />,
title: 'eventGroupOutbound',
events: [
{ key: 'node.down', label: 'eventNodeDown', settingKey: '' },
{ key: 'node.up', label: 'eventNodeUp', settingKey: '' },
{ key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' },
{ key: 'outbound.up', label: 'eventOutboundUp', settingKey: '' },
],
};
@@ -64,7 +64,7 @@ const meta = {
docs: {
description: {
component:
'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.',
'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.',
},
},
},
@@ -138,7 +138,7 @@ export const PartiallySelected: Story = {
export const NoneSelected: Story = {
args: {
config: nodeGroup,
config: outboundGroup,
selected: [],
onToggle: () => undefined,
onToggleAll: () => undefined,
@@ -61,7 +61,7 @@ export const TypicalMonitoring: Story = {
new AllSetting({
tgBotEnable: true,
tgBotChatId: '123456789',
tgEnabledEvents: 'node.down,cpu.high,memory.high,login.attempt',
tgEnabledEvents: 'xray.crash,node.down,cpu.high,memory.high,login.attempt',
tgCpu: 85,
tgMemory: 90,
})
@@ -77,7 +77,8 @@ export const EverythingEnabled: Story = {
initial={
new AllSetting({
tgBotEnable: true,
tgEnabledEvents: 'node.down,node.up,cpu.high,memory.high,login.attempt',
tgEnabledEvents:
'outbound.down,outbound.up,xray.crash,node.down,node.up,cpu.high,memory.high,login.attempt',
tgCpu: 70,
tgMemory: 75,
})
@@ -1,11 +1,45 @@
import { InputNumber } from 'antd';
import { DesktopOutlined, DashboardOutlined, SafetyOutlined } from '@ant-design/icons';
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',
+3 -1
View File
@@ -12,10 +12,12 @@ interface SubPageData {
expire?: string | number;
lastOnline?: string | number;
subUrl?: string;
subJsonUrl?: string;
subClashUrl?: string;
subTitle?: string;
links?: string[];
emails?: string[];
datepicker?: 'gregorian';
datepicker?: 'gregorian' | 'jalalian';
announce?: string;
downloadByte?: string | number;
uploadByte?: string | number;
+181 -25
View File
@@ -3,23 +3,83 @@ export const EXAMPLES: Record<string, unknown> = {
"AllSetting": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"ipLimitAllowlist": "",
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInsecureSkipVerify": false,
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"outboundDownThreshold": 1,
"pageSize": 0,
"panelOutbound": "",
"remarkTemplate": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "",
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonObservatory": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 0,
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
@@ -36,8 +96,9 @@ export const EXAMPLES: Record<string, unknown> = {
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trafficStatsSwitches": "",
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "",
"webCertFile": "",
@@ -49,27 +110,90 @@ export const EXAMPLES: Record<string, unknown> = {
"AllSettingView": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"hasApiToken": false,
"hasLdapPassword": false,
"hasNordSecret": false,
"hasSmtpPassword": false,
"hasTgBotToken": false,
"hasTwoFactorToken": false,
"hasWarpSecret": false,
"ipLimitAllowlist": "",
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInsecureSkipVerify": false,
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"outboundDownThreshold": 1,
"pageSize": 0,
"panelOutbound": "",
"remarkTemplate": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "",
"subCertFile": "",
"subClashAutoDetect": false,
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
"subDomain": "",
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonAlwaysArray": false,
"subJsonAutoDetect": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonObservatory": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subJsonUserAgentRegex": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 0,
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false,
"subSupportUrl": "",
"subThemeDir": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
@@ -86,8 +210,9 @@ export const EXAMPLES: Record<string, unknown> = {
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trafficStatsSwitches": "",
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"warpUpdateInterval": 0,
"webBasePath": "",
"webCertFile": "",
@@ -96,6 +221,26 @@ export const EXAMPLES: Record<string, unknown> = {
"webListen": "",
"webPort": 1
},
"AmneziaWGLogs": {
"events": [
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
],
"peers": [
{
"allowedIPs": "10.8.1.2/32",
"down": 4194304,
"email": "peer@example.com",
"endpoint": "203.0.113.9:51820",
"handshake": 1735732800000,
"inboundId": 1,
"interface": "awg1",
"online": true,
"tag": "inbound-51820",
"up": 1048576
}
],
"running": true
},
"ApiToken": {
"createdAt": 0,
"enabled": false,
@@ -256,6 +401,12 @@ export const EXAMPLES: Record<string, unknown> = {
"name": "geosite.dat",
"size": 1467392
},
"GeodataTokenIssue": {
"code": "blabla",
"file": "geosite.dat",
"reason": "categoryMissing",
"token": "geosite:blabla"
},
"HistoryOfSeeders": {
"id": 0,
"seederName": ""
@@ -379,7 +530,6 @@ export const EXAMPLES: Record<string, unknown> = {
"enable": true,
"expiryTime": 0,
"fallbackParent": null,
"forwarding": "",
"id": 1,
"lastTrafficResetTime": 0,
"listen": "",
@@ -498,23 +648,16 @@ export const EXAMPLES: Record<string, unknown> = {
""
],
"name": "",
"nodeType": "traditional",
"outboundTag": "",
"pinnedCertSha256": "",
"port": 0,
"port": 1,
"remark": "",
"scheme": "http",
"tlsVerifyMode": "verify"
},
"NodeView": {
"activeCount": 20,
"activeEmails": [
"a@example.com"
],
"address": "node.example.com",
"agentUpdateError": "",
"agentUpdateState": "failed",
"agentUpdateTarget": "A1.0.1",
"allowPrivateAddress": false,
"basePath": "/",
"clientCount": 25,
@@ -523,13 +666,7 @@ export const EXAMPLES: Record<string, unknown> = {
"cpuPct": 12.5,
"createdAt": 1700000000,
"depletedCount": 1,
"depletedEmails": [
"c@example.com"
],
"disabledCount": 2,
"disabledEmails": [
"b@example.com"
],
"enable": true,
"guid": "node-guid",
"hasApiToken": true,
@@ -539,8 +676,6 @@ export const EXAMPLES: Record<string, unknown> = {
"inboundTags": [
"in-443-tcp"
],
"ipv4": "203.0.113.7",
"ipv6": "2001:db8::7",
"lastError": "",
"lastHeartbeat": 1700000000,
"latencyMs": 42,
@@ -548,11 +683,7 @@ export const EXAMPLES: Record<string, unknown> = {
"name": "edge-1",
"netDown": 1048576,
"netUp": 2097152,
"nodeType": "traditional",
"onlineCount": 5,
"onlineEmails": [
"a@example.com"
],
"outboundTag": "direct",
"panelVersion": "v3.x.x",
"parentGuid": "",
@@ -582,6 +713,18 @@ export const EXAMPLES: Record<string, unknown> = {
"runId": "1735689600123456789",
"state": "success"
},
"PeerActivity": {
"allowedIPs": "10.8.1.2/32",
"down": 4194304,
"email": "peer@example.com",
"endpoint": "203.0.113.9:51820",
"handshake": 1735732800000,
"inboundId": 1,
"interface": "awg1",
"online": true,
"tag": "inbound-51820",
"up": 1048576
},
"ProbeResultUI": {
"cpuPct": 12.5,
"error": "",
@@ -662,6 +805,19 @@ export const EXAMPLES: Record<string, unknown> = {
"key": "",
"value": ""
},
"SubBalancer": {
"createdAt": 1710000000000,
"enabled": true,
"id": 1,
"inboundIds": [
1,
3
],
"remark": "auto-fastest",
"sortOrder": 1,
"strategy": "random",
"updatedAt": 1710000000000
},
"User": {
"id": 0,
"password": "",
File diff suppressed because it is too large Load Diff
+164 -15
View File
@@ -4,7 +4,6 @@ export type OnlineAPISupport = number;
export type ProcessState = string;
export type Protocol = string;
export type SubLinkProvider = unknown;
export type rawBytes = number[];
export type staticEgressResolver = string;
export type trafficLocalApplyAction = number;
export type transportBits = number;
@@ -12,23 +11,83 @@ export type transportBits = number;
export interface AllSetting {
datepicker: string;
expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
ipLimitAllowlist: string;
ldapAutoCreate: boolean;
ldapAutoDelete: boolean;
ldapBaseDN: string;
ldapBindDN: string;
ldapDefaultExpiryDays: number;
ldapDefaultLimitIP: number;
ldapDefaultTotalGB: number;
ldapEnable: boolean;
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInsecureSkipVerify: boolean;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
ldapSyncCron: string;
ldapTruthyValues: string;
ldapUseTLS: boolean;
ldapUserAttr: string;
ldapUserFilter: string;
ldapVlessField: string;
outboundDownThreshold: number;
pageSize: number;
panelOutbound: string;
remarkTemplate: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string;
subClashRules: string;
subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;
subJsonObservatory: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string;
subListen: string;
subPath: string;
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
subURI: string;
subUpdates: number;
@@ -45,8 +104,9 @@ export interface AllSetting {
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trafficStatsSwitches: string;
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string;
webCertFile: string;
@@ -59,27 +119,90 @@ export interface AllSetting {
export interface AllSettingView {
datepicker: string;
expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
hasApiToken: boolean;
hasLdapPassword: boolean;
hasNordSecret: boolean;
hasSmtpPassword: boolean;
hasTgBotToken: boolean;
hasTwoFactorToken: boolean;
hasWarpSecret: boolean;
ipLimitAllowlist: string;
ldapAutoCreate: boolean;
ldapAutoDelete: boolean;
ldapBaseDN: string;
ldapBindDN: string;
ldapDefaultExpiryDays: number;
ldapDefaultLimitIP: number;
ldapDefaultTotalGB: number;
ldapEnable: boolean;
ldapFlagField: string;
ldapHost: string;
ldapInboundTags: string;
ldapInsecureSkipVerify: boolean;
ldapInvertFlag: boolean;
ldapPassword: string;
ldapPort: number;
ldapSyncCron: string;
ldapTruthyValues: string;
ldapUseTLS: boolean;
ldapUserAttr: string;
ldapUserFilter: string;
ldapVlessField: string;
outboundDownThreshold: number;
pageSize: number;
panelOutbound: string;
remarkTemplate: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string;
subCertFile: string;
subClashAutoDetect: boolean;
subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string;
subClashRules: string;
subClashURI: string;
subClashUserAgentRegex: string;
subDomain: string;
subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean;
subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean;
subJsonEnable: boolean;
subJsonFinalMask: string;
subJsonMux: string;
subJsonObservatory: string;
subJsonPath: string;
subJsonRules: string;
subJsonURI: string;
subJsonUserAgentRegex: string;
subKeyFile: string;
subListen: string;
subPath: string;
subPort: number;
subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean;
subSupportUrl: string;
subThemeDir: string;
subTitle: string;
subURI: string;
subUpdates: number;
@@ -96,8 +219,9 @@ export interface AllSettingView {
tgRunTime: string;
timeLocation: string;
trafficDiff: number;
trafficStatsSwitches: string;
trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
warpUpdateInterval: number;
webBasePath: string;
webCertFile: string;
@@ -107,6 +231,12 @@ export interface AllSettingView {
webPort: number;
}
export interface AmneziaWGLogs {
events: string[];
peers: PeerActivity[];
running: boolean;
}
export interface ApiToken {
createdAt: number;
enabled: boolean;
@@ -261,6 +391,13 @@ export interface GeoFile {
size: number;
}
export interface GeodataTokenIssue {
code?: string;
file?: string;
reason: string;
token: string;
}
export interface HistoryOfSeeders {
id: number;
seederName: string;
@@ -344,7 +481,6 @@ export interface Inbound {
enable: boolean;
expiryTime: number;
fallbackParent?: FallbackParentInfo | null;
forwarding: string;
id: number;
lastTrafficResetTime: number;
listen: string;
@@ -465,7 +601,6 @@ export interface NodeMutationRequest {
inboundSyncMode: string;
inboundTags: string[];
name: string;
nodeType?: string;
outboundTag: string;
pinnedCertSha256: string;
port: number;
@@ -476,11 +611,7 @@ export interface NodeMutationRequest {
export interface NodeView {
activeCount: number;
activeEmails?: string[];
address: string;
agentUpdateError?: string;
agentUpdateState?: string;
agentUpdateTarget?: string;
allowPrivateAddress: boolean;
basePath: string;
clientCount: number;
@@ -489,9 +620,7 @@ export interface NodeView {
cpuPct: number;
createdAt: number;
depletedCount: number;
depletedEmails?: string[];
disabledCount: number;
disabledEmails?: string[];
enable: boolean;
guid: string;
hasApiToken: boolean;
@@ -499,8 +628,6 @@ export interface NodeView {
inboundCount: number;
inboundSyncMode: string;
inboundTags: string[];
ipv4?: string;
ipv6?: string;
lastError: string;
lastHeartbeat: number;
latencyMs: number;
@@ -508,9 +635,7 @@ export interface NodeView {
name: string;
netDown: number;
netUp: number;
nodeType?: string;
onlineCount: number;
onlineEmails?: string[];
outboundTag: string;
panelVersion: string;
parentGuid?: string;
@@ -543,6 +668,19 @@ export interface PanelUpdateStatus {
state: string;
}
export interface PeerActivity {
allowedIPs: string;
down: number;
email: string;
endpoint: string;
handshake: number;
inboundId: number;
interface: string;
online: boolean;
tag: string;
up: number;
}
export interface ProbeResultUI {
cpuPct: number;
error: string;
@@ -625,6 +763,17 @@ export interface Setting {
value: string;
}
export interface SubBalancer {
createdAt: number;
enabled: boolean;
id: number;
inboundIds: number[];
remark: string;
sortOrder: number;
strategy: string;
updatedAt: number;
}
export interface User {
id: number;
password: string;
+171 -20
View File
@@ -15,9 +15,6 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
export const SubLinkProviderSchema = z.unknown();
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
export const rawBytesSchema = z.array(z.number().int());
export type rawBytes = z.infer<typeof rawBytesSchema>;
export const staticEgressResolverSchema = z.string();
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
@@ -30,23 +27,83 @@ export type transportBits = z.infer<typeof transportBitsSchema>;
export const AllSettingSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
ipLimitAllowlist: z.string(),
ldapAutoCreate: z.boolean(),
ldapAutoDelete: z.boolean(),
ldapBaseDN: z.string(),
ldapBindDN: z.string(),
ldapDefaultExpiryDays: z.number().int().min(0),
ldapDefaultLimitIP: z.number().int().min(0),
ldapDefaultTotalGB: z.number().int().min(0),
ldapEnable: z.boolean(),
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInsecureSkipVerify: z.boolean(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
ldapSyncCron: z.string(),
ldapTruthyValues: z.string(),
ldapUseTLS: z.boolean(),
ldapUserAttr: z.string(),
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(),
remarkTemplate: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
subJsonObservatory: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
subPort: z.number().int().min(0).max(65535),
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
@@ -63,8 +120,9 @@ export const AllSettingSchema = z.object({
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trafficStatsSwitches: z.string(),
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
webCertFile: z.string(),
@@ -78,27 +136,90 @@ export type AllSetting = z.infer<typeof AllSettingSchema>;
export const AllSettingViewSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
hasApiToken: z.boolean(),
hasLdapPassword: z.boolean(),
hasNordSecret: z.boolean(),
hasSmtpPassword: z.boolean(),
hasTgBotToken: z.boolean(),
hasTwoFactorToken: z.boolean(),
hasWarpSecret: z.boolean(),
ipLimitAllowlist: z.string(),
ldapAutoCreate: z.boolean(),
ldapAutoDelete: z.boolean(),
ldapBaseDN: z.string(),
ldapBindDN: z.string(),
ldapDefaultExpiryDays: z.number().int().min(0),
ldapDefaultLimitIP: z.number().int().min(0),
ldapDefaultTotalGB: z.number().int().min(0),
ldapEnable: z.boolean(),
ldapFlagField: z.string(),
ldapHost: z.string(),
ldapInboundTags: z.string(),
ldapInsecureSkipVerify: z.boolean(),
ldapInvertFlag: z.boolean(),
ldapPassword: z.string(),
ldapPort: z.number().int().min(0).max(65535),
ldapSyncCron: z.string(),
ldapTruthyValues: z.string(),
ldapUseTLS: z.boolean(),
ldapUserAttr: z.string(),
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(),
remarkTemplate: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(),
subCertFile: z.string(),
subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(),
subClashUserAgentRegex: z.string(),
subDomain: z.string(),
subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(),
subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(),
subJsonFinalMask: z.string(),
subJsonMux: z.string(),
subJsonObservatory: z.string(),
subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(),
subListen: z.string(),
subPath: z.string(),
subPort: z.number().int().min(0).max(65535),
subPort: z.number().int().min(1).max(65535),
subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(),
subThemeDir: z.string(),
subTitle: z.string(),
subURI: z.string(),
subUpdates: z.number().int().min(0).max(525600),
@@ -115,8 +236,9 @@ export const AllSettingViewSchema = z.object({
tgRunTime: z.string(),
timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100),
trafficStatsSwitches: z.string(),
trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(),
webCertFile: z.string(),
@@ -127,6 +249,13 @@ export const AllSettingViewSchema = z.object({
});
export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
export const AmneziaWGLogsSchema = z.object({
events: z.array(z.string()),
peers: z.array(z.lazy(() => PeerActivitySchema)),
running: z.boolean(),
});
export type AmneziaWGLogs = z.infer<typeof AmneziaWGLogsSchema>;
export const ApiTokenSchema = z.object({
createdAt: z.number().int(),
enabled: z.boolean(),
@@ -294,6 +423,14 @@ export const GeoFileSchema = z.object({
});
export type GeoFile = z.infer<typeof GeoFileSchema>;
export const GeodataTokenIssueSchema = z.object({
code: z.string().optional(),
file: z.string().optional(),
reason: z.string(),
token: z.string(),
});
export type GeodataTokenIssue = z.infer<typeof GeodataTokenIssueSchema>;
export const HistoryOfSeedersSchema = z.object({
id: z.number().int(),
seederName: z.string(),
@@ -380,7 +517,6 @@ export const InboundSchema = z.object({
enable: z.boolean(),
expiryTime: z.number().int(),
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
forwarding: z.string(),
id: z.number().int(),
lastTrafficResetTime: z.number().int(),
listen: z.string(),
@@ -507,10 +643,9 @@ export const NodeMutationRequestSchema = z.object({
inboundSyncMode: z.enum(['all', 'selected']),
inboundTags: z.array(z.string()),
name: z.string(),
nodeType: z.enum(['traditional', 'agent']).optional(),
outboundTag: z.string(),
pinnedCertSha256: z.string(),
port: z.number().int().min(0).max(65535),
port: z.number().int().min(1).max(65535),
remark: z.string(),
scheme: z.enum(['http', 'https']),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
@@ -519,11 +654,7 @@ export type NodeMutationRequest = z.infer<typeof NodeMutationRequestSchema>;
export const NodeViewSchema = z.object({
activeCount: z.number().int(),
activeEmails: z.array(z.string()).optional(),
address: z.string(),
agentUpdateError: z.string().optional(),
agentUpdateState: z.string().optional(),
agentUpdateTarget: z.string().optional(),
allowPrivateAddress: z.boolean(),
basePath: z.string(),
clientCount: z.number().int(),
@@ -532,9 +663,7 @@ export const NodeViewSchema = z.object({
cpuPct: z.number(),
createdAt: z.number().int(),
depletedCount: z.number().int(),
depletedEmails: z.array(z.string()).optional(),
disabledCount: z.number().int(),
disabledEmails: z.array(z.string()).optional(),
enable: z.boolean(),
guid: z.string(),
hasApiToken: z.boolean(),
@@ -542,8 +671,6 @@ export const NodeViewSchema = z.object({
inboundCount: z.number().int(),
inboundSyncMode: z.string(),
inboundTags: z.array(z.string()),
ipv4: z.string().optional(),
ipv6: z.string().optional(),
lastError: z.string(),
lastHeartbeat: z.number().int(),
latencyMs: z.number().int(),
@@ -551,9 +678,7 @@ export const NodeViewSchema = z.object({
name: z.string(),
netDown: z.number().int(),
netUp: z.number().int(),
nodeType: z.string().optional(),
onlineCount: z.number().int(),
onlineEmails: z.array(z.string()).optional(),
outboundTag: z.string(),
panelVersion: z.string(),
parentGuid: z.string().optional(),
@@ -589,6 +714,20 @@ export const PanelUpdateStatusSchema = z.object({
});
export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
export const PeerActivitySchema = z.object({
allowedIPs: z.string(),
down: z.number().int(),
email: z.string(),
endpoint: z.string(),
handshake: z.number().int(),
inboundId: z.number().int(),
interface: z.string(),
online: z.boolean(),
tag: z.string(),
up: z.number().int(),
});
export type PeerActivity = z.infer<typeof PeerActivitySchema>;
export const ProbeResultUISchema = z.object({
cpuPct: z.number(),
error: z.string(),
@@ -675,6 +814,18 @@ export const SettingSchema = z.object({
});
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({
id: z.number().int(),
password: z.string(),
+52 -2
View File
@@ -51,6 +51,10 @@ const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as cons
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
subClashURI: string;
subClashEnable: boolean;
publicHost: string;
}
@@ -158,7 +162,7 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
}
export interface UseClientsOptions {
// Callers that only need the mutations — the bulk modals —
// Callers that only need the mutations — the bulk modals, the groups page —
// pass false. Mounting them used to start a second 5-second poll of the paged
// list whose result they never read, which on a large panel means a full
// summary aggregate every 5 seconds for nothing.
@@ -263,9 +267,22 @@ export function useClients(options: UseClientsOptions = {}) {
() => ({
enable: !!defaults.subEnable,
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) || '',
}),
[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;
@@ -287,6 +304,7 @@ export function useClients(options: UseClientsOptions = {}) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
]);
}, [queryClient]);
@@ -310,6 +328,22 @@ export function useClients(options: UseClientsOptions = {}) {
},
});
const bulkAddToGroupMut = useMutation({
mutationFn: (body: { emails: string[]; group: string }) =>
HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidateAll();
},
});
const bulkRemoveFromGroupMut = useMutation({
mutationFn: (body: { emails: string[] }) =>
HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidateAll();
},
});
const updateMut = useMutation({
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
@@ -549,6 +583,20 @@ export function useClients(options: UseClientsOptions = {}) {
},
[bulkSetEnableMut],
);
const bulkAddToGroup = useCallback(
(emails: string[], group: string) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAddToGroupMut.mutateAsync({ emails, group });
},
[bulkAddToGroupMut],
);
const bulkRemoveFromGroup = useCallback(
(emails: string[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkRemoveFromGroupMut.mutateAsync({ emails });
},
[bulkRemoveFromGroupMut],
);
const attach = useCallback(
(email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
@@ -759,6 +807,8 @@ export function useClients(options: UseClientsOptions = {}) {
bulkAdjust,
bulkEnable,
bulkDisable,
bulkAddToGroup,
bulkRemoveFromGroup,
attach,
setExternalLinks,
bulkAttach,
+1 -1
View File
@@ -3,7 +3,7 @@ import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { DefaultsPayloadSchema } from '@/schemas/defaults';
type Calendar = 'gregorian';
type Calendar = 'gregorian' | 'jalalian';
let cachedValue: Calendar = 'gregorian';
let fetched = false;
+6 -1
View File
@@ -6,9 +6,14 @@ const TITLE_KEYS: Record<string, string> = {
'/': 'menu.dashboard',
'/inbounds': 'menu.inbounds',
'/clients': 'menu.clients',
'/groups': 'menu.groups',
'/nodes': 'menu.nodes',
'/hosts': 'menu.hosts',
'/settings': 'menu.settings',
'/xray': 'menu.xray',
'/outbound': 'menu.outbounds',
'/routing': 'menu.routing',
'/api-docs': 'menu.apiDocs',
};
export function usePageTitle() {
@@ -17,7 +22,7 @@ export function usePageTitle() {
useEffect(() => {
const key = TITLE_KEYS[pathname];
const title = key ? t(key) : 'L-UI';
const title = key ? t(key) : '3X-UI';
const host = window.location.hostname;
document.title = host ? `${host} - ${title}` : title;
}, [pathname, t]);
+542
View File
@@ -0,0 +1,542 @@
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,
],
);
}
+40 -23
View File
@@ -6,9 +6,11 @@ import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
import {
ApiOutlined,
ApartmentOutlined,
CloseOutlined,
CloudServerOutlined,
ClusterOutlined,
CodeOutlined,
DashboardOutlined,
ExportOutlined,
GithubOutlined,
@@ -17,12 +19,10 @@ import {
ImportOutlined,
LogoutOutlined,
MenuOutlined,
MessageOutlined,
MoonFilled,
MoonOutlined,
PushpinFilled,
PushpinOutlined,
RadarChartOutlined,
ReadOutlined,
SafetyOutlined,
SettingOutlined,
@@ -173,7 +173,9 @@ export default function AppSidebar() {
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const navigate = useNavigate();
const { pathname, hash } = useLocation();
useAllSettings();
const { allSetting } = useAllSettings();
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
const showSubBalancers = !!allSetting.subJsonEnable;
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
const [pinned, setPinned] = useState(readSidebarPinned);
@@ -212,9 +214,14 @@ export default function AppSidebar() {
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') },
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
{ key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
{ key: '/routing', icon: 'routing', title: t('menu.routing') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
],
[t],
@@ -235,38 +242,45 @@ export default function AppSidebar() {
icon: <SafetyOutlined />,
label: t('pages.settings.securitySettings'),
},
{
key: '/settings#telegram',
icon: <MessageOutlined />,
label: t('pages.settings.TGBotSettings'),
},
{
key: '/settings#subscription',
icon: <CloudServerOutlined />,
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;
}, [t]);
}, [t, showSubFormats, showSubBalancers]);
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
() => [{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') }],
[t],
);
const settingsActive = pathname === '/settings';
const xrayActive = pathname === '/xray';
const selectedKey = settingsActive
? `/settings${hash || '#general'}`
: xrayActive
? `/xray${hash || '#basic'}`
: pathname === ''
? '/'
: pathname;
const openSubmenu = settingsActive ? '/settings' : null;
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
if (openSubmenu && !openKeys.includes(openSubmenu)) {
setOpenKeys([...openKeys, openSubmenu]);
@@ -279,9 +293,12 @@ export default function AppSidebar() {
if (tab.key === '/settings') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
}
if (tab.key === '/xray') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
}
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
}),
[settingsChildren],
[settingsChildren, xrayChildren],
);
const openLink = useCallback(
@@ -335,7 +352,7 @@ export default function AppSidebar() {
>
<div className="sider-brand">
<div className="brand-block">
<span className="brand-text">{railCollapsed ? 'L' : 'L-UI'}</span>
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
</div>
{!railCollapsed && (
<div className="brand-actions">
@@ -399,7 +416,7 @@ export default function AppSidebar() {
>
<div className="drawer-header">
<div className="brand-block">
<span className="drawer-brand">L-UI</span>
<span className="drawer-brand">3X-UI</span>
</div>
<div className="drawer-header-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
-3
View File
@@ -22,9 +22,6 @@ function parseVersionParts(version: string): [number, number, number] | null {
export function formatPanelVersion(version: string | undefined | null): string {
const v = (version || '').trim();
if (!v) return '';
// This fork's display versions (master "V1.0.0", agent "A1.0.0") are
// shown exactly as written — never re-prefixed or re-cased.
if (/^[VA]\d+\.\d+\.\d+$/.test(v)) return v;
const normalized = v.replace(/^v/i, '');
return /^\d/.test(normalized) ? `v${normalized}` : v;
}
@@ -62,7 +62,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="IP/CIDR"
placeholder="IP/CIDR/geoip:*/ext:*"
style={{ width: '100%' }}
/>
</Form.Item>
@@ -73,7 +73,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
<Select
mode="tags"
tokenSeparators={[',']}
placeholder="domain:*"
placeholder="domain:*/ext:*"
style={{ width: '100%' }}
/>
</Form.Item>
@@ -1,2 +0,0 @@
export { default as SockoptForm } from './sockopt';
export { default as MuxForm } from './mux';
+81
View File
@@ -0,0 +1,81 @@
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);
}
+6 -25
View File
@@ -1,9 +1,7 @@
import {
ForwardingSpecSchema,
type ForwardingSpec,
type InboundFormValues,
type ShareAddrStrategy,
type TrafficReset,
import type {
InboundFormValues,
ShareAddrStrategy,
TrafficReset,
} from '@/schemas/forms/inbound-form';
import type { InboundSettings } from '@/schemas/protocols/inbound';
import {
@@ -51,7 +49,6 @@ export interface RawInboundRow {
trafficResetDay?: number;
lastTrafficResetTime?: number;
nodeId?: number | null;
forwarding?: unknown;
shareAddrStrategy?: string;
shareAddr?: string;
subSortIndex?: number;
@@ -80,8 +77,7 @@ export interface WireInboundPayload {
sniffing: string;
tag: string;
clientStats?: unknown;
nodeId?: number | null;
forwarding: string;
nodeId?: number;
shareAddrStrategy: ShareAddrStrategy;
shareAddr: string;
subSortIndex: number;
@@ -115,11 +111,6 @@ function coerceTrafficReset(v: unknown): TrafficReset {
: 'never';
}
function coerceForwarding(v: unknown): ForwardingSpec {
const parsed = ForwardingSpecSchema.safeParse(coerceJsonObject(v));
return parsed.success ? parsed.data : ForwardingSpecSchema.parse({});
}
function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
? (v as ShareAddrStrategy)
@@ -222,7 +213,6 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
nodeId: row.nodeId ?? null,
forwarding: coerceForwarding(row.forwarding),
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
shareAddr: row.shareAddr ?? '',
subSortIndex: Math.max(1, row.subSortIndex ?? 1),
@@ -393,16 +383,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
shareAddr: values.shareAddr,
subSortIndex: values.subSortIndex,
disableFlow: values.disableFlow,
// Forwarding rides as a JSON string like settings; direct mode stores
// an empty string so the row carries no forwarding residue.
forwarding:
values.forwarding && values.forwarding.mode === 'forward'
? JSON.stringify(values.forwarding)
: '',
};
// 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;
if (values.nodeId != null) payload.nodeId = values.nodeId;
return payload;
}
+20 -27
View File
@@ -14,6 +14,7 @@ import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities';
import { deriveSpiderX } from './spider-x';
// Share-link generators. Each per-protocol fn takes a typed inbound plus
// client overrides and returns a URL (or '' when the protocol doesn't
@@ -352,6 +353,7 @@ export interface GenVlessLinkInput {
forceTls?: ForceTls;
remark?: string;
clientId: string;
clientKey?: string;
flow?: VlessClient['flow'];
externalProxy?: ExternalProxyEntry | null;
}
@@ -372,7 +374,7 @@ export function applyVlessRoute(id: string, route: string | undefined): string {
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
// query carries network type, encryption, network-specific knobs, and
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
// pbk/sid). Returns '' if the inbound isn't vless.
// pbk/sid/spx). Returns '' if the inbound isn't vless.
export function genVlessLink(input: GenVlessLinkInput): string {
const {
inbound,
@@ -381,6 +383,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
forceTls = 'same',
remark = '',
clientId,
clientKey = '',
flow = '',
externalProxy = null,
} = input;
@@ -459,6 +462,8 @@ export function genVlessLink(input: GenVlessLinkInput): string {
if (sni && sni.length > 0) params.set('sni', sni);
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);
}
@@ -555,6 +560,7 @@ function writeTlsParams(
function writeRealityParams(
stream: NonNullable<Inbound['streamSettings']>,
params: URLSearchParams,
clientKey: string,
): void {
if (stream.security !== 'reality') return;
const reality = stream.realitySettings;
@@ -567,6 +573,8 @@ function writeRealityParams(
if (sni && sni.length > 0) params.set('sni', sni);
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);
}
@@ -577,6 +585,7 @@ export interface GenTrojanLinkInput {
forceTls?: ForceTls;
remark?: string;
clientPassword: string;
clientKey?: string;
externalProxy?: ExternalProxyEntry | null;
}
@@ -591,6 +600,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
forceTls = 'same',
remark = '',
clientPassword,
clientKey = '',
externalProxy = null,
} = input;
@@ -611,7 +621,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
applyExternalProxyTLSParams(externalProxy, params, security);
} else if (security === 'reality') {
params.set('security', 'reality');
writeRealityParams(stream, params);
writeRealityParams(stream, params, clientKey);
} else {
params.set('security', 'none');
}
@@ -1213,31 +1223,6 @@ export interface ShareHostFields {
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
// analog of the backend resolveInboundAddress. hostOverride is the hosting node's
// address (empty for this panel's own inbounds); fallbackHostname is the
@@ -1311,6 +1296,12 @@ type ClientShape = {
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 {
switch (inbound.protocol) {
case 'vmess':
@@ -1376,6 +1367,7 @@ export function genLink(input: GenLinkInput): string {
forceTls,
remark,
clientId: client.id ?? '',
clientKey: clientSubKey(client),
flow: client.flow,
externalProxy,
});
@@ -1399,6 +1391,7 @@ export function genLink(input: GenLinkInput): string {
forceTls,
remark,
clientPassword: client.password ?? '',
clientKey: clientSubKey(client),
externalProxy,
});
case 'hysteria':
+10
View File
@@ -0,0 +1,10 @@
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)}`;
}
-3
View File
@@ -39,7 +39,6 @@ export type DBInboundInit = Partial<{
streamSettings: RawJsonField;
tag: string;
sniffing: RawJsonField;
forwarding: string;
clientStats: ClientStats[];
nodeId: number | null;
shareAddrStrategy: string;
@@ -89,7 +88,6 @@ export class DBInbound {
streamSettings: RawJsonField;
tag: string;
sniffing: RawJsonField;
forwarding: string;
clientStats: ClientStats[];
nodeId: number | null;
shareAddrStrategy: string;
@@ -121,7 +119,6 @@ export class DBInbound {
this.streamSettings = '';
this.tag = '';
this.sniffing = '';
this.forwarding = '';
this.clientStats = [];
this.nodeId = null;
this.shareAddrStrategy = 'node';
+73 -2
View File
@@ -9,12 +9,14 @@ export class AllSetting {
webBasePath = '/';
sessionMaxAge = 360;
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
ipLimitAllowlist = '';
panelOutbound = '';
pageSize = 25;
expireDiff = 0;
trafficDiff = 0;
remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
subShowIdentityOnAllLinks = false;
datepicker = 'gregorian' as const;
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
tgBotEnable = false;
tgBotToken = '';
tgBotAPIServer = '';
@@ -24,32 +26,97 @@ export class AllSetting {
tgCpu = 80;
tgMemory = 80;
tgLang = 'en-US';
trafficStatsSwitches = '';
twoFactorEnable = false;
twoFactorToken = '';
xrayTemplateConfig = '';
subEnable = true;
subJsonEnable = false;
subJsonAutoDetect = false;
subJsonAlwaysArray = false;
subJsonUserAgentRegex = '';
subClashAutoDetect = false;
subClashUserAgentRegex = '';
subTitle = '';
subSupportUrl = '';
subProfileUrl = '';
subAnnounce = '';
subEnableRouting = false;
subRoutingRules = '';
subIncyEnableRouting = false;
subIncyRoutingRules = '';
subListen = '';
subPort = 2096;
subPath = '/sub/';
subJsonPath = '/json/';
subClashEnable = false;
subClashPath = '/clash/';
subDomain = '';
externalTrafficInformEnable = false;
externalTrafficInformURI = '';
restartXrayOnClientDisable = true;
subCertFile = '';
subKeyFile = '';
subUpdates = 12;
subEncrypt = true;
subURI = '';
subJsonURI = '';
subClashURI = '';
subClashEnableRouting = false;
subClashRules = '';
subJsonMux = '';
subJsonRules = '';
subJsonFinalMask = '';
subJsonObservatory = '';
subThemeDir = '';
subHideSettings = false;
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';
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;
hasTwoFactorToken = false;
hasLdapPassword = false;
hasApiToken = false;
hasWarpSecret = false;
hasNordSecret = false;
hasSmtpPassword = false;
clearTgBotToken = false;
clearLdapPassword = false;
clearSmtpPassword = false;
constructor(data?: unknown) {
if (data != null) {
@@ -57,6 +124,10 @@ export class AllSetting {
}
const cpu = Math.round(Number(this.tgCpu));
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 {
+421
View File
@@ -0,0 +1,421 @@
.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);
}
@@ -0,0 +1,46 @@
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
@@ -0,0 +1,89 @@
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,8 +37,6 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'hysteria',
'wireguard',
'amneziawg',
'socks',
'mixed',
]);
const EMPTY: ClientBulkAddFormValues = {
@@ -61,8 +61,6 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'wireguard',
'mtproto',
'amneziawg',
'socks',
'mixed',
]);
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -51,6 +51,10 @@ const INBOUND_CHIP_LIMIT = 1;
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
subClashURI: string;
subClashEnable: boolean;
publicHost?: string;
}
@@ -72,11 +76,17 @@ interface ApiMsg<T = unknown> {
const DEFAULT_SUB: SubSettings = {
enable: false,
subURI: '',
subJsonURI: '',
subJsonEnable: false,
subClashURI: '',
subClashEnable: false,
publicHost: '',
};
const SUBSCRIPTION_DOWNLOAD_NAMES = {
standard: 'subscription-standard.txt',
json: 'subscription-json.json',
clash: 'subscription-clash.yaml',
} as const;
export default function ClientInfoModal({
@@ -160,6 +170,14 @@ export default function ClientInfoModal({
const subId = client?.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 wgInbound = useMemo(
@@ -593,6 +611,120 @@ export default function ClientInfoModal({
</Popover>
</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>
)}
</>
)}
+18 -2
View File
@@ -20,6 +20,8 @@ import {
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
publicHost?: string;
}
@@ -40,6 +42,8 @@ interface ApiMsg<T = unknown> {
const DEFAULT_SUB: SubSettings = {
enable: false,
subURI: '',
subJsonURI: '',
subJsonEnable: false,
publicHost: '',
};
@@ -58,6 +62,10 @@ export default function ClientQrModal({
const subId = client?.subId;
const subEnabled = !!subSettings?.enable;
const subLink = subId && subEnabled && subSettings?.subURI ? subSettings.subURI + subId : '';
const subJsonLink =
subId && subEnabled && subSettings?.subJsonEnable && subSettings?.subJsonURI
? subSettings.subJsonURI + subId
: '';
const wgInbound = useMemo(
() => findWireguardInbound(client, inboundsById),
@@ -89,7 +97,8 @@ export default function ClientQrModal({
);
}, [client, awgInbound, tunnelAllowedIPs, subSettings?.publicHost]);
const hasAnything = !!subLink || !!wgConfigText || !!awgConfigText || links.length > 0;
const hasAnything =
!!subLink || !!subJsonLink || !!wgConfigText || !!awgConfigText || links.length > 0;
// The reset runs during render so the effect only carries the request.
const openSubId = open ? (client?.subId ?? '') : '';
@@ -133,6 +142,13 @@ 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) => {
const parts = parseLinkParts(link);
const meta = parts ? linkMetaText(parts) : '';
@@ -191,7 +207,7 @@ export default function ClientQrModal({
});
}
return out;
}, [subLink, wgConfigText, awgConfigText, links, client?.email, t]);
}, [subLink, subJsonLink, wgConfigText, awgConfigText, links, client?.email, t]);
// Expanding the first panel is a render-time adjustment, not a side effect.
const firstKey = open && items.length > 0 ? items[0].key : null;
@@ -43,6 +43,7 @@ import {
SearchOutlined,
SortAscendingOutlined,
StopOutlined,
TagsOutlined,
TeamOutlined,
UploadOutlined,
UsergroupAddOutlined,
@@ -82,6 +83,7 @@ const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
const FilterDrawer = lazy(() => import('./FilterDrawer'));
const SubLinksModal = lazy(() => import('./SubLinksModal'));
const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
@@ -95,6 +97,45 @@ const FILTER_STATE_KEY = 'clientsFilterState';
const DISABLED_PAGE_SIZE = 200;
const DEFAULT_TABLE_PAGE_SIZE = 25;
function UngroupIcon() {
return (
<span
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '1em',
height: '1em',
}}
>
<TagsOutlined />
<span
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<span
style={{
display: 'block',
width: '125%',
height: '1.5px',
background: 'currentColor',
transform: 'rotate(-45deg)',
borderRadius: '1px',
}}
/>
</span>
</span>
);
}
// The server sends exact counters but caps the email arrays behind them, so a
// panel with thousands of depleted clients neither ships nor renders them all.
// The trailing chip reports what the popover left out.
@@ -273,6 +314,8 @@ export default function ClientsPage() {
bulkAdjust,
bulkEnable,
bulkDisable,
bulkAddToGroup,
bulkRemoveFromGroup,
attach,
setExternalLinks,
bulkAttach,
@@ -320,6 +363,7 @@ export default function ClientsPage() {
const [bulkAddOpen, setBulkAddOpen] = useState(false);
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
const [subLinksOpen, setSubLinksOpen] = useState(false);
const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
const [bulkAttachOpen, setBulkAttachOpen] = useState(false);
const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
@@ -802,6 +846,27 @@ export default function ClientsPage() {
});
}
function onBulkUngroup() {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
modal.confirm({
title: t('pages.clients.ungroupConfirmTitle', { count: emails.length }),
content: t('pages.clients.ungroupConfirmContent'),
okText: t('confirm'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await bulkRemoveFromGroup(emails);
if (msg?.success) {
setSelectedRowKeys([]);
const affected =
(msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
messageApi.success(t('pages.clients.ungroupSuccessToast', { count: affected }));
}
},
});
}
function onBulkSetEnable(enable: boolean) {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
@@ -1317,6 +1382,19 @@ export default function ClientsPage() {
danger: true,
onClick: () => setBulkDetachOpen(true),
},
{
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.clients.addToGroup'),
onClick: () => setBulkGroupOpen(true),
},
{
key: 'ungroup',
icon: <UngroupIcon />,
label: t('pages.clients.ungroup'),
danger: true,
onClick: onBulkUngroup,
},
{ type: 'divider' as const },
{
key: 'enable',
@@ -1834,6 +1912,22 @@ export default function ClientsPage() {
onOpenChange={setSubLinksOpen}
/>
</LazyMount>
<LazyMount when={bulkGroupOpen}>
<BulkAddToGroupModal
open={bulkGroupOpen}
count={selectedRowKeys.length}
groups={allGroups}
onOpenChange={setBulkGroupOpen}
onSubmit={async (group) => {
const msg = await bulkAddToGroup([...selectedRowKeys], group);
if (msg?.success) {
setSelectedRowKeys([]);
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={bulkAttachOpen}>
<BulkAttachInboundsModal
open={bulkAttachOpen}
+31 -2
View File
@@ -9,6 +9,8 @@ import type { ClientRecord } from '@/hooks/useClients';
interface SubSettings {
enable: boolean;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
}
interface SubLinksModalProps {
@@ -24,6 +26,7 @@ interface Row {
email: string;
subId: string;
link: string;
jsonLink: string;
}
export default function SubLinksModal({
@@ -37,6 +40,7 @@ export default function SubLinksModal({
const [messageApi, messageContextHolder] = message.useMessage();
const enabled = !!subSettings?.enable && !!subSettings?.subURI;
const jsonEnabled = !!subSettings?.subJsonEnable && !!subSettings?.subJsonURI;
const rows = useMemo<Row[]>(() => {
if (!enabled) return [];
@@ -50,12 +54,21 @@ export default function SubLinksModal({
email,
subId: c.subId,
link: subSettings!.subURI + c.subId,
jsonLink: jsonEnabled ? subSettings!.subJsonURI + c.subId : '',
});
}
return out;
}, [emails, clients, enabled, subSettings]);
}, [emails, clients, enabled, jsonEnabled, subSettings]);
const allText = useMemo(() => rows.map((r) => `${r.email}\t${r.link}`).join('\n'), [rows]);
const allText = useMemo(
() =>
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) {
try {
@@ -116,6 +129,22 @@ 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 (
<>
{messageContextHolder}
@@ -0,0 +1,170 @@
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>
);
}
@@ -0,0 +1,150 @@
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>
);
}
+708
View File
@@ -0,0 +1,708 @@
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 '@/lib/xray/forms/outbound/transport';
import { MuxForm } from '@/pages/xray/outbounds/transport';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
import { serializeOverride } from './helpers';
@@ -1,4 +1,4 @@
import { SockoptForm } from '@/lib/xray/forms/outbound/transport';
import { SockoptForm } from '@/pages/xray/outbounds/transport';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
+19 -5
View File
@@ -30,7 +30,6 @@ import {
genInboundLinks,
genWireguardLinks,
preferPublicHost,
shareHostForNode,
} from '@/lib/xray/inbound-link';
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
import { Protocols } from '@/schemas/primitives';
@@ -54,6 +53,7 @@ const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
type RowAction =
| 'edit'
@@ -68,6 +68,7 @@ type RowAction =
| 'attachClients'
| 'attachExisting'
| 'detachClients'
| 'addToGroup'
| 'clone';
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
@@ -164,6 +165,9 @@ export default function InboundsPage() {
const [detachOpen, setDetachOpen] = useState(false);
const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
const [groupOpen, setGroupOpen] = useState(false);
const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
const [cloneOpen, setCloneOpen] = useState(false);
const [cloneSource, setCloneSource] = useState<DBInbound | null>(null);
@@ -188,10 +192,7 @@ export default function InboundsPage() {
const hostOverrideFor = useCallback(
(dbInbound: DBInbound | null) => {
if (!dbInbound || dbInbound.nodeId == null) return '';
// 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));
return nodesById.get(dbInbound.nodeId)?.address || '';
},
[nodesById],
);
@@ -621,6 +622,7 @@ export default function InboundsPage() {
'clipboard',
'clone',
'attachClients',
'addToGroup',
];
let target = dbInbound;
if (hydratingKeys.includes(key)) {
@@ -670,6 +672,10 @@ export default function InboundsPage() {
setDetachSource(target);
setDetachOpen(true);
break;
case 'addToGroup':
setGroupSource(target);
setGroupOpen(true);
break;
case 'clone':
confirmClone(target);
break;
@@ -842,6 +848,14 @@ export default function InboundsPage() {
source={detachSource}
/>
</LazyMount>
<LazyMount when={groupOpen}>
<AddClientsToGroupModal
open={groupOpen}
onClose={() => setGroupOpen(false)}
onAdded={refresh}
source={groupSource}
/>
</LazyMount>
<LazyMount when={cloneOpen}>
<CloneInboundModal
open={cloneOpen}
@@ -0,0 +1,65 @@
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 };
}}
/>
);
}
@@ -1,173 +0,0 @@
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,7 +82,6 @@ import { useSecurityActions } from './useSecurityActions';
import { useInboundFallbacks } from './useInboundFallbacks';
import FallbacksCard from './FallbacksCard';
import SniffingTab from './SniffingTab';
import ForwardingTab from './ForwardingTab';
import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
@@ -151,7 +150,6 @@ function firstRhfValidationIssue(
function tabForValidationPath(path: PropertyKey[]): string {
if (path[0] === 'settings') return 'protocol';
if (path[0] === 'sniffing') return 'sniffing';
if (path[0] === 'forwarding') return 'forwarding';
if (path[0] === 'streamSettings') {
if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings')
return 'security';
@@ -620,6 +618,7 @@ export default function InboundFormModal({
<FormField name="nodeId" label={t('pages.inbounds.deployTo')}>
<Select
showSearch
disabled={mode === 'edit'}
placeholder={t('pages.inbounds.localPanel')}
allowClear
options={selectableNodes.map((n) => ({
@@ -1080,10 +1079,6 @@ export default function InboundFormModal({
const sniffingTab = <SniffingTab />;
const forwardingTab = (
<ForwardingTab currentId={mode === 'edit' && dbInbound ? dbInbound.id : undefined} />
);
return (
<>
{messageContextHolder}
@@ -1117,12 +1112,6 @@ export default function InboundFormModal({
children: basicTab,
forceRender: true,
},
{
key: 'forwarding',
label: t('pages.inbounds.forwardingTab'),
children: forwardingTab,
forceRender: true,
},
...((
[
Protocols.VLESS,
@@ -54,6 +54,7 @@ export default function InboundInfoModal({
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
const [subLink, setSubLink] = useState('');
const [subJsonLink, setSubJsonLink] = useState('');
const [refreshing, setRefreshing] = useState(false);
const [clientIpsArray, setClientIpsArray] = useState<string[]>([]);
const [clientIpsText, setClientIpsText] = useState('');
@@ -197,8 +198,12 @@ export default function InboundInfoModal({
if (clientSet?.subId) {
setSubLink((subSettings?.subURI || '') + clientSet.subId);
setSubJsonLink(
subSettings?.subJsonEnable ? (subSettings?.subJsonURI || '') + clientSet.subId : '',
);
} else {
setSubLink('');
setSubJsonLink('');
}
setClientIpsArray([]);
@@ -570,6 +575,29 @@ export default function InboundInfoModal({
{subLink}
</a>
</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,11 +180,3 @@
padding: 4px;
}
}
.forwarding-tag {
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
@@ -137,17 +137,11 @@ export default function InboundList({
if (ok) setSelectedRowKeys([]);
}, [onBulkDelete, selectedRowKeys]);
const inboundsById = useMemo(
() => new Map(dbInbounds.map((ib) => [ib.id, ib] as const)),
[dbInbounds],
);
const columns = useInboundColumns({
hasAnyRemark,
hasAnySubSortIndex,
hasActiveNode,
nodesById,
inboundsById,
clientCount,
inboundSpeed,
subEnable,
@@ -10,6 +10,7 @@ import {
BlockOutlined,
DeleteOutlined,
InfoCircleOutlined,
TagsOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
} from '@ant-design/icons';
@@ -90,6 +91,11 @@ export function buildRowActionsMenu({
icon: <UsergroupDeleteOutlined />,
label: t('pages.inbounds.detachClients'),
});
items.push({
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.inbounds.addClientsToGroup'),
});
items.push({ type: 'divider' });
items.push({
key: 'delAllClients',
@@ -90,8 +90,6 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
case 'mtproto':
case 'wireguard':
case 'amneziawg':
case 'socks':
case 'mixed':
return true;
case 'shadowsocks':
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
@@ -106,26 +104,3 @@ export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
}
return false;
}
// Landing label for the list-row forwarding chip: the managed target's
// remark (falling back to its tag), or the manual endpoint address:port.
// Returns null when the inbound does not forward, so the caller renders
// no chip. The stored spec rides the row as a JSON string.
export function forwardingLandingLabel(
record: DBInboundRecord,
inboundsById: Map<number, DBInboundRecord>,
): string | null {
const spec = coerceInboundJsonField(record.forwarding) as {
mode?: string;
landing?: string;
targetInboundId?: number;
address?: string;
port?: number;
};
if (spec.mode !== 'forward') return null;
if (spec.landing === 'manual') {
return spec.address ? `${spec.address}:${spec.port ?? 0}` : 'SK5';
}
const target = spec.targetInboundId ? inboundsById.get(spec.targetInboundId) : undefined;
return target?.remark || target?.tag || 'SK5';
}
@@ -32,10 +32,8 @@ export interface DBInboundRecord extends ProtocolFlags {
expiryTime: number;
_expiryTime: { valueOf(): number } | null;
nodeId?: number | null;
tag?: string;
settings: unknown;
streamSettings: unknown;
forwarding?: unknown;
}
export interface ClientCountEntry {
@@ -17,7 +17,6 @@ import {
} from '@/components/utility/speedTagStyle';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import {
forwardingLandingLabel,
readStreamHints,
networkLabel,
networkL4,
@@ -32,7 +31,6 @@ interface UseInboundColumnsParams {
hasAnySubSortIndex: boolean;
hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>;
inboundsById: Map<number, DBInboundRecord>;
clientCount: Record<number, ClientCountEntry>;
inboundSpeed: Record<number, InboundSpeedEntry>;
subEnable: boolean;
@@ -47,7 +45,6 @@ export function useInboundColumns({
hasAnySubSortIndex,
hasActiveNode,
nodesById,
inboundsById,
clientCount,
inboundSpeed,
subEnable,
@@ -260,17 +257,6 @@ export function useInboundColumns({
</Tag>,
);
}
const fwdLabel = forwardingLandingLabel(record, inboundsById);
if (fwdLabel) {
const text = `${t('pages.inbounds.forwardingTab')} → ${fwdLabel}`;
tags.push(
<Tooltip key="fwd" title={text}>
<Tag color="volcano" className="forwarding-tag">
{text}
</Tag>
</Tooltip>,
);
}
return <div className="protocol-tags">{tags}</div>;
},
},
@@ -467,7 +453,6 @@ export function useInboundColumns({
hasAnySubSortIndex,
hasActiveNode,
nodesById,
inboundsById,
clientCount,
inboundSpeed,
subEnable,
+21 -1
View File
@@ -55,6 +55,7 @@ export default function QrCodeModal({
const [amneziawgConfigs, setAmneziawgConfigs] = useState<string[]>([]);
const [amneziawgLinks, setAmneziawgLinks] = useState<string[]>([]);
const [subLink, setSubLink] = useState('');
const [subJsonLink, setSubJsonLink] = useState('');
const [activeKey, setActiveKey] = useState<string[]>([]);
// Building the links is a pure function of the props, so it runs during
@@ -144,10 +145,13 @@ export default function QrCodeModal({
const subId = client?.subId;
let nextSub = '';
let nextSubJson = '';
if (subSettings?.enable && subId) {
nextSub = (subSettings.subURI || '') + subId;
nextSubJson = subSettings.subJsonEnable ? (subSettings.subJsonURI || '') + subId : '';
}
setSubLink(nextSub);
setSubJsonLink(nextSubJson);
}
const qrItems = useMemo<QrItem[]>(() => {
@@ -155,6 +159,13 @@ export default function QrCodeModal({
if (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) => {
items.push({ key: `l${idx}`, header: link.remark || `Link ${idx + 1}`, value: link.link });
});
@@ -191,7 +202,16 @@ export default function QrCodeModal({
}
});
return items;
}, [subLink, links, wireguardConfigs, wireguardLinks, amneziawgConfigs, amneziawgLinks, t]);
}, [
subLink,
subJsonLink,
links,
wireguardConfigs,
wireguardLinks,
amneziawgConfigs,
amneziawgLinks,
t,
]);
const collapseItems: CollapseProps['items'] = useMemo(
() =>
+11 -2
View File
@@ -20,6 +20,8 @@ export interface SubSettings {
enable: boolean;
subTitle: string;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
// 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
// is set.
@@ -177,19 +179,23 @@ export function useInbounds() {
const tgBotEnable = !!defaults.tgBotEnable;
const ipLimitEnable = !!defaults.ipLimitEnable;
const pageSize = defaults.pageSize ?? 0;
const datepicker = (defaults.datepicker as 'gregorian') || 'gregorian';
const datepicker = (defaults.datepicker as 'gregorian' | 'jalalian') || 'gregorian';
const subSettings: SubSettings = useMemo(
() => ({
enable: !!defaults.subEnable,
subTitle: defaults.subTitle || '',
subURI: defaults.subURI || '',
subJsonURI: defaults.subJsonURI || '',
subJsonEnable: !!defaults.subJsonEnable,
publicHost: defaults.subDomain || defaults.webDomain || '',
}),
[
defaults.subEnable,
defaults.subTitle,
defaults.subURI,
defaults.subJsonURI,
defaults.subJsonEnable,
defaults.subDomain,
defaults.webDomain,
],
@@ -386,13 +392,16 @@ export function useInbounds() {
// Invalidate at the inbounds root so both `slim` (this page's list)
// and `options` (the Clients page's inbound picker) refetch. Without
// the options bucket, a freshly-created inbound stays invisible in
// the client add/edit modal until a full page reload.
// the client add/edit modal until a full page reload. The xray config
// response carries inboundTags for the routing-rule tag picker, so it
// needs invalidating too or that list stays stale until a hard refresh.
await Promise.all([
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
]);
}, [queryClient]);
@@ -0,0 +1,17 @@
.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;
}
@@ -0,0 +1,254 @@
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>
);
}
+54
View File
@@ -38,6 +38,53 @@
flex-wrap: wrap;
}
.ov-state {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border: 1px solid var(--ov-line);
border-radius: 999px;
font-size: 13px;
color: var(--ant-color-text);
}
.ov-state-dot {
position: relative;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex: none;
}
.ov-state[data-state='running'] .ov-state-dot::after {
content: '';
position: absolute;
inset: -1px;
border-radius: 50%;
border: 1px solid currentColor;
animation: ovPulse 1.6s infinite ease-out;
}
@keyframes ovPulse {
0% {
transform: scale(0.9);
opacity: 0.5;
}
100% {
transform: scale(2.4);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ov-state[data-state='running'] .ov-state-dot::after {
animation: none;
}
}
.ov-state-version,
.ov-panel-version {
padding: 0;
border: 0;
@@ -48,6 +95,8 @@
transition: color 0.2s;
}
.ov-state-version:hover,
.ov-state-version:focus-visible,
.ov-panel-version:hover,
.ov-panel-version:focus-visible {
color: var(--ant-color-primary);
@@ -63,6 +112,11 @@
margin-inline-end: 0;
}
.ov-error-detail {
white-space: pre-wrap;
word-break: break-word;
}
.ov-bar-actions {
margin-inline-start: auto;
display: flex;
+58 -17
View File
@@ -32,7 +32,12 @@ import { mean, peak, useOverviewHistory } from './useOverviewHistory';
import type { PanelUpdateInfo } from './PanelUpdateModal';
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
const LogModal = lazy(() => import('./LogModal'));
const BackupModal = lazy(() => import('./BackupModal'));
const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
const XrayLogModal = lazy(() => import('./XrayLogModal'));
const AmneziaWGLogModal = lazy(() => import('./AmneziaWGLogModal'));
import './IndexPage.css';
export default function IndexPage() {
@@ -45,8 +50,7 @@ export default function IndexPage() {
setMessageInstance(messageApi);
}, [messageApi]);
const history = useOverviewHistory(status, fetched && !fetchError);
const [accessLogEnable, setAccessLogEnable] = useState(false);
const [devChannelEnable, setDevChannelEnable] = useState(false);
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
currentVersion: '',
@@ -57,21 +61,29 @@ export default function IndexPage() {
const basePath = window.X_UI_BASE_PATH || '';
const [showIp, setShowIp] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [backupOpen, setBackupOpen] = useState(false);
const [panelUpdateOpen, setPanelUpdateOpen] = useState(false);
const [sysHistoryOpen, setSysHistoryOpen] = useState(false);
const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false);
const [xrayLogsOpen, setXrayLogsOpen] = useState(false);
const [amneziawgLogsOpen, setAmneziawgLogsOpen] = useState(false);
const [configTextOpen, setConfigTextOpen] = useState(false);
const [configText, setConfigText] = useState('');
const [loading, setLoading] = useState(false);
const [loadingTip, setLoadingTip] = useState(t('loading'));
const history = useOverviewHistory(status, fetched && !fetchError);
useEffect(() => {
HttpUtil.post<{ devChannelEnable?: boolean }>('/panel/api/setting/defaultSettings').then(
(msg) => {
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
'/panel/api/setting/defaultSettings',
).then((msg) => {
if (msg?.success && msg.obj) {
setAccessLogEnable(!!msg.obj.accessLogEnable);
setDevChannelEnable(!!msg.obj.devChannelEnable);
}
},
);
});
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
});
@@ -87,6 +99,16 @@ export default function IndexPage() {
if (tip) setLoadingTip(tip);
}, []);
const stopXray = useCallback(async () => {
await HttpUtil.post('/panel/api/server/stopXrayService');
await refresh();
}, [refresh]);
const restartXray = useCallback(async () => {
await HttpUtil.post('/panel/api/server/restartXrayService');
await refresh();
}, [refresh]);
async function handleChannelChange(dev: boolean) {
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
if (!res?.success) return;
@@ -170,21 +192,21 @@ export default function IndexPage() {
) : (
<div className="ov-page">
<OverviewActionBar
status={status}
isMobile={isMobile}
accessLogEnable={accessLogEnable}
panelVersion={displayVersion}
latestVersion={
panelUpdateInfo.channel === 'dev'
? panelUpdateInfo.latestVersion
: panelUpdateInfo.displayLatestVersion || panelUpdateInfo.latestVersion
}
updateAvailable={
panelUpdateInfo.channel === 'dev'
? panelUpdateInfo.updateAvailable
: (panelUpdateInfo.displayUpdateAvailable ??
panelUpdateInfo.updateAvailable)
}
latestVersion={panelUpdateInfo.latestVersion}
updateAvailable={panelUpdateInfo.updateAvailable}
onStopXray={stopXray}
onRestartXray={restartXray}
onOpenLogs={() => setLogsOpen(true)}
onOpenXrayLogs={() => setXrayLogsOpen(true)}
onOpenAmneziaWGLogs={() => setAmneziawgLogsOpen(true)}
onOpenConfig={openConfig}
onOpenBackup={() => setBackupOpen(true)}
onOpenSystemHistory={() => setSysHistoryOpen(true)}
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
/>
@@ -282,6 +304,9 @@ export default function IndexPage() {
onBusy={setBusy}
/>
</LazyMount>
<LazyMount when={logsOpen}>
<LogModal open={logsOpen} onClose={() => setLogsOpen(false)} />
</LazyMount>
<LazyMount when={backupOpen}>
<BackupModal
open={backupOpen}
@@ -290,6 +315,22 @@ export default function IndexPage() {
onBusy={setBusy}
/>
</LazyMount>
<LazyMount when={sysHistoryOpen}>
<SystemHistoryModal
open={sysHistoryOpen}
status={status}
onClose={() => setSysHistoryOpen(false)}
/>
</LazyMount>
<LazyMount when={xrayMetricsOpen}>
<XrayMetricsModal open={xrayMetricsOpen} onClose={() => setXrayMetricsOpen(false)} />
</LazyMount>
<LazyMount when={xrayLogsOpen}>
<XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
</LazyMount>
<LazyMount when={amneziawgLogsOpen}>
<AmneziaWGLogModal open={amneziawgLogsOpen} onClose={() => setAmneziawgLogsOpen(false)} />
</LazyMount>
<LazyMount when={configTextOpen}>
<Modal
open={configTextOpen}
+172
View File
@@ -0,0 +1,172 @@
.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;
}
+202
View File
@@ -0,0 +1,202 @@
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>
);
}
+115 -3
View File
@@ -1,17 +1,39 @@
import { Fragment } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Tag, Tooltip } from 'antd';
import { CloudDownloadOutlined, CloudServerOutlined, ControlOutlined } from '@ant-design/icons';
import {
ApiOutlined,
ArrowUpOutlined,
AreaChartOutlined,
BarsOutlined,
CloudDownloadOutlined,
CloudServerOutlined,
ControlOutlined,
FileTextOutlined,
PoweroffOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { formatPanelVersion } from '@/lib/panel-version';
import type { Status } from '@/models/status';
interface OverviewActionBarProps {
status: Status;
isMobile: boolean;
accessLogEnable: boolean;
panelVersion: string;
latestVersion: string;
updateAvailable: boolean;
onStopXray: () => void;
onRestartXray: () => void;
onOpenLogs: () => void;
onOpenXrayLogs: () => void;
onOpenAmneziaWGLogs: () => void;
onOpenConfig: () => void;
onOpenBackup: () => void;
onOpenSystemHistory: () => void;
onOpenXrayMetrics: () => void;
onOpenPanelUpdate: () => void;
}
@@ -23,19 +45,73 @@ interface BarAction {
primary?: boolean;
}
const XRAY_STATE_KEYS: Record<string, string> = {
running: 'pages.index.xrayStatusRunning',
stop: 'pages.index.xrayStatusStop',
error: 'pages.index.xrayStatusError',
};
export default function OverviewActionBar({
status,
isMobile,
accessLogEnable,
panelVersion,
latestVersion,
updateAvailable,
onStopXray,
onRestartXray,
onOpenLogs,
onOpenXrayLogs,
onOpenAmneziaWGLogs,
onOpenConfig,
onOpenBackup,
onOpenSystemHistory,
onOpenXrayMetrics,
onOpenPanelUpdate,
}: OverviewActionBarProps) {
const { t } = useTranslation();
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
const hasVersion = !!status.xray.version && status.xray.version !== 'Unknown';
const size = isMobile ? ('small' as const) : ('middle' as const);
const actions: BarAction[] = [
const actionGroups: BarAction[][] = [
[
{
key: 'restart',
icon: <ReloadOutlined />,
text: t('pages.index.restartXray'),
onClick: onRestartXray,
primary: true,
},
{
key: 'stop',
icon: <PoweroffOutlined />,
text: t('pages.index.stopXray'),
onClick: onStopXray,
},
],
[
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
...(accessLogEnable
? [
{
key: 'accessLogs',
icon: <FileTextOutlined />,
text: t('pages.index.accessLogs'),
onClick: onOpenXrayLogs,
},
]
: []),
...(status.amneziawg.configured
? [
{
key: 'amneziawgLogs',
icon: <ApiOutlined />,
text: t('pages.index.amneziawgLogs'),
onClick: onOpenAmneziaWGLogs,
},
]
: []),
{
key: 'config',
icon: <ControlOutlined />,
@@ -48,10 +124,41 @@ export default function OverviewActionBar({
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 (
<div className="ov-bar">
{status.xray.state === 'error' && status.xray.errorMsg ? (
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
{statePill}
</Tooltip>
) : (
statePill
)}
{updateAvailable ? (
<Tag
className="ov-update-tag"
@@ -70,7 +177,10 @@ export default function OverviewActionBar({
)}
<div className="ov-bar-actions">
{actions.map((action) => (
{actionGroups.map((group, groupIndex) => (
<Fragment key={group[0].key}>
{groupIndex > 0 && <span className="ov-bar-sep" />}
{group.map((action) => (
<Button
key={action.key}
type={action.primary ? undefined : 'text'}
@@ -84,6 +194,8 @@ export default function OverviewActionBar({
{isMobile ? undefined : action.text}
</Button>
))}
</Fragment>
))}
</div>
</div>
);
+6 -21
View File
@@ -17,11 +17,6 @@ export interface PanelUpdateInfo {
currentCommit?: string;
latestCommit?: string;
updateAvailable: boolean;
// V-series display versions (items 18/20); the stable channel's update
// decision and labels use these when present.
displayCurrentVersion?: string;
displayLatestVersion?: string;
displayUpdateAvailable?: boolean;
}
interface BusyEvent {
@@ -52,13 +47,6 @@ export default function PanelUpdateModal({
const isDev = info.channel === 'dev';
// On the stable channel the V-series display comparison decides (item
// 20); the dev channel keeps its commit-based decision.
const masterUpdateAvailable = isDev
? info.updateAvailable
: (info.displayUpdateAvailable ?? info.updateAvailable);
const displayLatest = info.displayLatestVersion || info.latestVersion;
async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> {
await PromiseUtil.sleep(5000);
const deadline = Date.now() + 90_000;
@@ -97,14 +85,13 @@ export default function PanelUpdateModal({
title: t('pages.index.panelUpdateDialog'),
content: t('pages.index.panelUpdateDialogDesc').replace(
'#version#',
isDev ? info.latestVersion || '' : displayLatest || '',
info.latestVersion || '',
),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
const baseTip = t('pages.index.dontRefresh');
const tipVersion = isDev ? info.latestVersion : displayLatest;
const tip = tipVersion ? `${baseTip} (${tipVersion})` : baseTip;
const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip;
onClose();
onBusy({ busy: true, tip });
const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel');
@@ -141,7 +128,7 @@ export default function PanelUpdateModal({
<>
{contextHolder}
<Modal open={open} title={t('pages.index.updatePanel')} footer={null} onCancel={onClose}>
{masterUpdateAvailable && (
{info.updateAvailable && (
<Alert
type="warning"
className="mb-12"
@@ -179,14 +166,12 @@ export default function PanelUpdateModal({
</Tag>
)}
</div>
{masterUpdateAvailable ? (
{info.updateAvailable ? (
<div className="version-list-item">
<span>
{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}
</span>
<Tag color="purple">
{(isDev ? info.latestCommit : formatPanelVersion(displayLatest)) || '-'}
</Tag>
<Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag>
</div>
) : (
<div className="version-list-item">
@@ -199,7 +184,7 @@ export default function PanelUpdateModal({
<div className="actions-row">
<Button
type="primary"
disabled={!masterUpdateAvailable}
disabled={!info.updateAvailable}
onClick={updatePanel}
icon={<CloudDownloadOutlined />}
>
@@ -0,0 +1,34 @@
.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);
}
@@ -0,0 +1,406 @@
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>
);
}
+9 -2
View File
@@ -29,13 +29,20 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
<ClockCircleOutlined />
{t('pages.index.uptime')}
</div>
{/* The lite master runs no local xray core, so the uptime card
shows the OS uptime only (the Xray half always read 0s). */}
<div className="ov-strip-split">
<div>
<div className="ov-strip-sub">Xray</div>
<div className="ov-strip-value">
{TimeFormatter.formatSecond(status.appStats.uptime)}
</div>
</div>
<span className="ov-strip-split-sep" />
<div>
<div className="ov-strip-sub">OS</div>
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
</div>
</div>
</div>
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
+151
View File
@@ -0,0 +1,151 @@
.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);
}
+295
View File
@@ -0,0 +1,295 @@
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>
);
}
@@ -0,0 +1,71 @@
.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;
}
}
@@ -0,0 +1,428 @@
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>
);
}
+119
View File
@@ -0,0 +1,119 @@
// 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 };
}
+52 -4
View File
@@ -1,7 +1,19 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ConfigProvider, Form, Input, Layout, Menu, Popover, Space, message } from 'antd';
import {
Button,
ConfigProvider,
Form,
Input,
Layout,
Menu,
Popover,
Space,
Spin,
message,
} from 'antd';
import {
KeyOutlined,
LockOutlined,
MoonFilled,
MoonOutlined,
@@ -15,7 +27,7 @@ import { HttpUtil, LanguageManager } from '@/utils';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { LoginFormSchema, type LoginFormValues } from '@/schemas/login';
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
import './LoginPage.css';
const HEADLINE_INTERVAL_MS = 2000;
@@ -33,10 +45,12 @@ export default function LoginPage() {
setMessageInstance(messageApi);
}, [messageApi]);
const [fetched, setFetched] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [twoFactorEnable, setTwoFactorEnable] = useState(false);
const [headlineIndex, setHeadlineIndex] = useState(0);
const methods = useForm<LoginForm>({
defaultValues: { username: '', password: '' },
defaultValues: { username: '', password: '', twoFactorCode: '' },
});
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
@@ -49,6 +63,19 @@ export default function LoginPage() {
return () => window.clearInterval(timer);
}, [headlineWords.length]);
useEffect(() => {
let cancelled = false;
(async () => {
const msg = await HttpUtil.post('/getTwoFactorEnable');
if (cancelled) return;
if (msg.success) setTwoFactorEnable(!!msg.obj);
setFetched(true);
})();
return () => {
cancelled = true;
};
}, []);
const onSubmit = useCallback(async (values: LoginForm) => {
setSubmitting(true);
try {
@@ -145,9 +172,14 @@ export default function LoginPage() {
</div>
<div className="login-wrapper">
{!fetched ? (
<div className="login-loading">
<Spin size="large" />
</div>
) : (
<div className="login-card">
<div className="brand">
<span className="brand-name">L-UI</span>
<span className="brand-name">3X-UI</span>
<span className="brand-accent" aria-hidden="true" />
</div>
<h2 className="welcome">
@@ -187,6 +219,21 @@ export default function LoginPage() {
/>
</FormField>
{twoFactorEnable && (
<FormField
name="twoFactorCode"
label={t('twoFactorCode')}
rules={{ validate: rhfZodValidate(TwoFactorCodeSchema) }}
>
<Input
prefix={<KeyOutlined />}
autoComplete="one-time-code"
size="large"
placeholder={t('twoFactorCode')}
/>
</FormField>
)}
<Form.Item className="submit-row">
<Button
type="primary"
@@ -201,6 +248,7 @@ export default function LoginPage() {
</Form>
</FormProvider>
</div>
)}
</div>
</Layout.Content>
</Layout>
+4 -241
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import {
Alert,
Button,
@@ -17,12 +16,10 @@ import {
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
import { HttpUtil, type Msg } from '@/utils';
import type { Msg } from '@/utils';
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import { currentPanelMasterUrl } from './agentMasterUrl';
import './NodeFormModal.css';
type Mode = 'add' | 'edit';
@@ -43,7 +40,6 @@ function defaultValues(): NodeFormValues {
id: 0,
name: '',
remark: '',
nodeType: 'traditional',
scheme: 'https',
address: '',
port: 2053,
@@ -71,7 +67,6 @@ export default function NodeFormModal({
onOpenChange,
}: NodeFormModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const methods = useForm<NodeFormValues>({ defaultValues: defaultValues() });
const [messageApi, messageContextHolder] = message.useMessage();
@@ -81,25 +76,9 @@ export default function NodeFormModal({
const [fetchingInbounds, setFetchingInbounds] = useState(false);
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
const [agentToken, setAgentToken] = useState<string | null>(null);
const [agentNodeName, setAgentNodeName] = useState('');
const [agentMasterUrl, setAgentMasterUrl] = useState('');
// Edit mode (item 19): the stored install material for an existing agent
// node, fetched from the master; null until loaded / for non-agent nodes.
// Keyed by node id so a stale result from a previously edited node is
// never shown (visibility is derived during render below).
const [editInstall, setEditInstall] = useState<{
nodeId: number;
name: string;
token: string;
hasToken: boolean;
} | null>(null);
const [editMasterUrl, setEditMasterUrl] = useState('');
const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
const nodeType = useWatch({ control: methods.control, name: 'nodeType' }) ?? 'traditional';
const isAgent = nodeType === 'agent';
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector);
@@ -150,39 +129,6 @@ export default function NodeFormModal({
setTestResult(null);
}
// Load the stored install material when editing an agent node so the
// full install command can be re-displayed (item 19). Nodes created
// before tokens were stored answer hasToken=false until the agent's
// next authenticated request backfills the token.
const editNodeId = mode === 'edit' && isAgent ? (node?.id ?? 0) : 0;
useEffect(() => {
if (!open || !editNodeId) return;
let cancelled = false;
HttpUtil.get<{ name: string; token: string; hasToken: boolean }>(
`/panel/api/agent/install/${editNodeId}`,
).then((msg) => {
if (!cancelled && msg?.success && msg.obj) {
setEditInstall({ nodeId: editNodeId, ...msg.obj });
setEditMasterUrl(currentPanelMasterUrl());
}
});
return () => {
cancelled = true;
};
}, [open, editNodeId]);
const shownInstall = editInstall && editInstall.nodeId === editNodeId ? editInstall : null;
const editInstallCmd = useMemo(() => {
if (!shownInstall || !editMasterUrl) return '';
const base =
`bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh)` +
` --master ${editMasterUrl}`;
return shownInstall.hasToken
? `${base} --token ${shownInstall.token} --name ${shownInstall.name}`
: `${base} --name ${shownInstall.name}`;
}, [shownInstall, editMasterUrl]);
const title = useMemo(
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
[mode, t],
@@ -192,16 +138,13 @@ export default function NodeFormModal({
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
const token = values.apiToken.trim();
const agentMode = values.nodeType === 'agent';
const payload: Partial<NodeRecord> = {
id: values.id || 0,
name: values.name.trim(),
remark: values.remark?.trim() || '',
nodeType: values.nodeType,
scheme: values.scheme,
// Agent nodes have no panel address; the backend ignores these.
address: agentMode ? 'agent' : values.address.trim(),
port: agentMode ? 2053 : values.port,
address: values.address.trim(),
port: values.port,
basePath: values.basePath.trim() || '/',
enable: values.enable,
allowPrivateAddress: values.allowPrivateAddress,
@@ -274,9 +217,6 @@ export default function NodeFormModal({
setSubmitting(true);
try {
const payload = buildPayload(result.data);
const agentMode = result.data.nodeType === 'agent';
// Agent nodes have no panel to probe; skip the connection test.
if (!agentMode) {
const test = await testConnection(payload);
const probe = test?.success ? test.obj : null;
if (!probe || probe.status !== 'online') {
@@ -286,25 +226,10 @@ export default function NodeFormModal({
return;
}
setTestResult(probe);
}
const msg = await save(payload);
if (msg?.success) {
// Backend returns { node, agentToken, nodeType } for agent nodes.
const obj = msg.obj as unknown as
| { agentToken?: string; node?: { id?: number } }
| undefined;
if (agentMode && mode === 'add' && obj?.agentToken) {
setAgentToken(obj.agentToken);
setAgentNodeName(result.data.name.trim());
// Default the master URL to the address this panel is being
// viewed through — origin AND base path. Dropping the base
// path made the agent's registration 404. User can edit it.
setAgentMasterUrl(currentPanelMasterUrl());
// Keep the modal open state; the token dialog shows on top.
} else {
onOpenChange(false);
}
}
} finally {
setSubmitting(false);
}
@@ -314,13 +239,6 @@ export default function NodeFormModal({
if (!submitting) onOpenChange(false);
}
// The install command is only meaningful with a master URL; an empty
// one would produce a command that can never register, so withhold it
// (and say why) instead of letting the user copy a dead command.
const installCmd = agentMasterUrl
? `bash <(curl -fL https://git.881218.xyz/rogue/3x-ui-agent/raw/branch/main/install-agent.sh) --master ${agentMasterUrl} --token ${agentToken ?? ''} --name ${agentNodeName}`
: '';
return (
<>
{messageContextHolder}
@@ -337,27 +255,6 @@ export default function NodeFormModal({
>
<FormProvider {...methods}>
<Form layout="vertical">
<Row gutter={16}>
<Col xs={24}>
<FormField label={t('pages.nodes.nodeType')} name="nodeType">
<Select
disabled={mode === 'edit'}
options={[
{ value: 'traditional', label: t('pages.nodes.nodeTypeTraditional') },
{ value: 'agent', label: t('pages.nodes.nodeTypeAgent') },
]}
/>
</FormField>
</Col>
</Row>
{isAgent && (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.agentHint')}
/>
)}
<Row gutter={16}>
<Col xs={24} md={12}>
<FormField
@@ -375,7 +272,6 @@ export default function NodeFormModal({
</Col>
</Row>
{!isAgent && (
<Row gutter={16}>
<Col xs={24} md={6}>
<FormField
@@ -412,9 +308,7 @@ export default function NodeFormModal({
</FormField>
</Col>
</Row>
)}
{!isAgent && (
<Row gutter={16}>
<Col xs={24} md={12}>
<FormField label={t('pages.nodes.basePath')} name="basePath">
@@ -427,67 +321,7 @@ export default function NodeFormModal({
</FormField>
</Col>
</Row>
)}
{isAgent && (
<Row gutter={16}>
<Col xs={24}>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<Switch />
</FormField>
</Col>
</Row>
)}
{mode === 'edit' && isAgent && shownInstall && (
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>{t('pages.nodes.agentInstallCommand')}</div>
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
<Input
value={editMasterUrl}
onChange={(e) => setEditMasterUrl(e.target.value.trim())}
style={{ marginBottom: 12, fontFamily: 'monospace' }}
/>
{!shownInstall.hasToken && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
title={t('pages.nodes.agentTokenNotStored')}
/>
)}
<Input.TextArea
value={editInstallCmd}
readOnly
rows={4}
style={{ fontFamily: 'monospace', marginBottom: 12 }}
/>
<Button
disabled={!editInstallCmd}
onClick={() => {
navigator.clipboard.writeText(editInstallCmd).then(
() => messageApi.success(t('pages.nodes.agentTokenCopied')),
() => messageApi.error(t('pages.nodes.agentTokenCopyFailed')),
);
}}
>
{t('pages.nodes.copyToken')}
</Button>
</div>
)}
{mode === 'edit' && isAgent && node?.id ? (
<div style={{ marginBottom: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{t('pages.nodes.probe.title')}
</div>
<p style={{ marginBottom: 8 }}>{t('pages.nodes.probe.movedHint')}</p>
<Button onClick={() => navigate('/settings#probe')}>
{t('pages.nodes.probe.goSettings')}
</Button>
</div>
) : null}
{!isAgent && (
<FormField
label={t('pages.nodes.allowPrivateAddress')}
name="allowPrivateAddress"
@@ -496,9 +330,7 @@ export default function NodeFormModal({
>
<Switch />
</FormField>
)}
{!isAgent && (
<FormField
label={t('pages.nodes.tlsVerifyMode')}
name="tlsVerifyMode"
@@ -514,7 +346,6 @@ export default function NodeFormModal({
]}
/>
</FormField>
)}
{tlsVerifyMode === 'skip' && (
<Alert
@@ -549,7 +380,6 @@ export default function NodeFormModal({
</FormField>
)}
{!isAgent && (
<FormField
label={t('pages.nodes.apiToken')}
name="apiToken"
@@ -565,7 +395,6 @@ export default function NodeFormModal({
}
/>
</FormField>
)}
<FormField
label={t('pages.nodes.outboundTag')}
@@ -626,7 +455,6 @@ export default function NodeFormModal({
</FormField>
)}
{!isAgent && (
<div className="test-row">
<Button type="default" loading={testing} onClick={onTest}>
{t('pages.nodes.testConnection')}
@@ -653,74 +481,9 @@ export default function NodeFormModal({
</div>
)}
</div>
)}
</Form>
</FormProvider>
</Modal>
{/* Agent install command - shown once after creating an agent node */}
<Modal
open={agentToken !== null}
title={t('pages.nodes.agentTokenTitle')}
width={640}
footer={[
<Button
key="copy"
type="primary"
disabled={!installCmd}
onClick={() => {
navigator.clipboard.writeText(installCmd).then(
() => messageApi.success(t('pages.nodes.agentTokenCopied')),
() => messageApi.error(t('pages.nodes.agentTokenCopyFailed')),
);
}}
>
{t('pages.nodes.copyToken')}
</Button>,
<Button
key="close"
onClick={() => {
setAgentToken(null);
onOpenChange(false);
}}
>
{t('close')}
</Button>,
]}
onCancel={() => {
setAgentToken(null);
onOpenChange(false);
}}
>
<Alert
type="success"
showIcon
style={{ marginBottom: 16 }}
title={t('pages.nodes.agentTokenCreated', { name: agentNodeName })}
/>
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
<Input
value={agentMasterUrl}
onChange={(e) => setAgentMasterUrl(e.target.value.trim())}
style={{ marginBottom: 12, fontFamily: 'monospace' }}
/>
{!agentMasterUrl && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
title={t('pages.nodes.agentMasterUrlRequired')}
/>
)}
<p>{t('pages.nodes.agentTokenHint')}</p>
<Input.TextArea
value={installCmd}
placeholder={t('pages.nodes.agentMasterUrlRequired')}
readOnly
rows={4}
style={{ fontFamily: 'monospace' }}
/>
<p style={{ marginTop: 12 }}>{t('pages.nodes.agentTokenInstructions')}</p>
</Modal>
</>
);
}
-11
View File
@@ -127,17 +127,6 @@
margin: 0;
}
/* Category chip hover list (same values as the inbounds page). */
.client-email-list {
max-height: 200px;
overflow-y: auto;
min-width: 150px;
}
.client-email-list > div {
padding: 2px 0;
}
.card-history {
margin-top: 4px;
padding-top: 8px;
+44 -143
View File
@@ -1,18 +1,6 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Card,
Dropdown,
Modal,
Popover,
Space,
Switch,
Table,
Tag,
Tooltip,
} from 'antd';
import { Badge, Button, Card, Dropdown, Modal, Space, Switch, Table, Tag, Tooltip } from 'antd';
import type { BadgeProps } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -27,7 +15,6 @@ import {
InfoCircleOutlined,
MoreOutlined,
PlusOutlined,
RadarChartOutlined,
RightOutlined,
SafetyCertificateOutlined,
TeamOutlined,
@@ -49,7 +36,6 @@ interface NodeListProps {
onSelectionChange: (ids: number[]) => void;
onAdd: () => void;
onMtls: () => void;
onProbeTasks: () => void;
onEdit: (node: NodeRecord) => void;
onDelete: (node: NodeRecord) => void;
onProbe: (node: NodeRecord) => void;
@@ -62,45 +48,6 @@ function isUpdateEligible(n: NodeRecord): boolean {
return !!n.enable && n.status === 'online';
}
// One category chip of the nodes table's clients cell. When the backend
// sent the category's email list, hovering pops the list over (same
// pattern as the inbounds page); without a list it keeps the plain
// category tooltip. Chip styling is identical either way.
function CategoryChip({
title,
emails,
color,
count,
}: {
title: string;
emails?: string[];
color?: string;
count: number;
}) {
const tag = (
<Tag color={color} className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
{count}
</Tag>
);
if (emails && emails.length > 0) {
return (
<Popover
title={title}
content={
<div className="client-email-list">
{emails.map((e) => (
<div key={e}>{e}</div>
))}
</div>
}
>
{tag}
</Popover>
);
}
return <Tooltip title={title}>{tag}</Tooltip>;
}
interface NodeRow extends NodeRecord {
url: string;
key: string | number;
@@ -220,7 +167,6 @@ export default function NodeList({
onSelectionChange,
onAdd,
onMtls,
onProbeTasks,
onEdit,
onDelete,
onProbe,
@@ -293,7 +239,7 @@ export default function NodeList({
{
title: t('pages.nodes.actions'),
align: 'center',
width: 150,
width: 190,
render: (_value, record) =>
record.transitive ? (
<Tooltip
@@ -318,23 +264,13 @@ export default function NodeList({
/>
</Tooltip>
{isUpdateEligible(record) && (
<Tooltip
title={t(
record.nodeType === 'agent'
? 'pages.nodes.updateAgent'
: 'pages.nodes.updatePanel',
)}
>
<Tooltip title={t('pages.nodes.updatePanel')}>
<Button
type="text"
size="small"
style={{ fontSize: 16 }}
icon={<CloudDownloadOutlined />}
aria-label={t(
record.nodeType === 'agent'
? 'pages.nodes.updateAgent'
: 'pages.nodes.updatePanel',
)}
aria-label={t('pages.nodes.updatePanel')}
onClick={() => onUpdateNode(record)}
/>
</Tooltip>
@@ -367,7 +303,7 @@ export default function NodeList({
title: t('pages.nodes.enable'),
dataIndex: 'enable',
align: 'center',
width: 64,
width: 80,
render: (_value, record) =>
record.transitive ? (
<span style={{ opacity: 0.4 }}>—</span>
@@ -426,28 +362,8 @@ export default function NodeList({
</span>
),
dataIndex: 'url',
width: 330,
ellipsis: true,
render: (_value, record) => {
if (record.nodeType === 'agent') {
// Agent nodes have no panel to link to: show the host IPs the
// agent reports with its heartbeat as plain text on one line
// (P13, layout per item 21).
const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
return (
<span className={showAddress ? 'address-visible' : 'address-hidden'}>
{ips.length > 0
? ips.map((ip, i) => (
<span key={ip}>
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
{ip}
</span>
))
: '-'}
</span>
);
}
return (
render: (_value, record) => (
<a
href={record.url}
target="_blank"
@@ -456,8 +372,7 @@ export default function NodeList({
>
{record.url}
</a>
);
},
),
},
{
title: t('pages.nodes.status'),
@@ -493,28 +408,17 @@ export default function NodeList({
render: (_value, record) => formatPct(record.memPct),
},
{
// Core version (V2.0.1 ②): agent rows run sing-box and show
// its version; traditional rows keep showing the xray one.
title: t('pages.nodes.coreVersion'),
title: t('pages.nodes.xrayVersion'),
dataIndex: 'xrayVersion',
align: 'center',
render: (_value, record) =>
record.nodeType === 'agent'
? record.singboxVersion || record.xrayVersion || '-'
: record.xrayVersion || '-',
render: (_value, record) => record.xrayVersion || '-',
},
{
title: t('pages.nodes.panelVersion') || 'Panel version',
dataIndex: 'panelVersion',
align: 'center',
render: (_value, record) => {
// Agent rows carry the A-series display version ("A1.0.0"), not
// an upstream panel version: the update badge must never light
// up for them (the version comparator falls back to string
// inequality for unparseable versions, which would always
// differ) (P13, item 18).
const canUpdate =
record.nodeType !== 'agent' &&
isUpdateEligible(record) &&
isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
return (
@@ -547,42 +451,51 @@ export default function NodeList({
{
title: t('clients'),
align: 'center',
width: 150,
width: 180,
render: (_value, record) => (
<Space size={2}>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
<TeamOutlined /> {record.clientCount || 0}
</Tag>
{record.activeCount ? (
<CategoryChip
title={t('subscription.active')}
emails={record.activeEmails}
<Tooltip title={t('subscription.active')}>
<Tag
color="green"
count={record.activeCount}
/>
className="client-count-tag"
style={{ margin: 0, padding: '0 2px' }}
>
{record.activeCount}
</Tag>
</Tooltip>
) : null}
{record.disabledCount ? (
<CategoryChip
title={t('disabled')}
emails={record.disabledEmails}
count={record.disabledCount}
/>
<Tooltip title={t('disabled')}>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
{record.disabledCount}
</Tag>
</Tooltip>
) : null}
{record.depletedCount ? (
<CategoryChip
title={t('depleted')}
emails={record.depletedEmails}
<Tooltip title={t('depleted')}>
<Tag
color="red"
count={record.depletedCount}
/>
className="client-count-tag"
style={{ margin: 0, padding: '0 2px' }}
>
{record.depletedCount}
</Tag>
</Tooltip>
) : null}
{record.onlineCount ? (
<CategoryChip
title={t('online')}
emails={record.onlineEmails}
<Tooltip title={t('online')}>
<Tag
color="blue"
count={record.onlineCount}
/>
className="client-count-tag"
style={{ margin: 0, padding: '0 2px' }}
>
{record.onlineCount}
</Tag>
</Tooltip>
) : null}
</Space>
),
@@ -591,7 +504,7 @@ export default function NodeList({
title: t('pages.nodes.latency'),
dataIndex: 'latencyMs',
align: 'center',
width: 84,
width: 100,
render: (_value, record) =>
record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
},
@@ -599,7 +512,7 @@ export default function NodeList({
title: t('pages.nodes.lastHeartbeat'),
dataIndex: 'lastHeartbeat',
align: 'center',
width: 96,
width: 120,
render: (_value, record) => relativeTime(record.lastHeartbeat),
},
],
@@ -626,9 +539,6 @@ export default function NodeList({
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
{t('pages.nodes.mtls.title')}
</Button>
<Button icon={<RadarChartOutlined />} onClick={onProbeTasks}>
{t('pages.nodes.probeTasks.title')}
</Button>
{selectedIds.length > 0 && (
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
@@ -719,12 +629,7 @@ export default function NodeList({
key: 'update',
label: (
<>
<CloudDownloadOutlined />{' '}
{t(
record.nodeType === 'agent'
? 'pages.nodes.updateAgent'
: 'pages.nodes.updatePanel',
)}
<CloudDownloadOutlined /> {t('pages.nodes.updatePanel')}
</>
),
onClick: () => onUpdateNode(record),
@@ -845,12 +750,8 @@ export default function NodeList({
<Tag>{formatPct(statsNode.memPct)}</Tag>
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.nodes.coreVersion')}</span>
<Tag>
{statsNode.nodeType === 'agent'
? statsNode.singboxVersion || statsNode.xrayVersion || '-'
: statsNode.xrayVersion || '-'}
</Tag>
<span className="stat-label">{t('pages.nodes.xrayVersion')}</span>
<Tag>{statsNode.xrayVersion || '-'}</Tag>
</div>
<div className="stat-row">
<span className="stat-label">
-15
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import {
Alert,
@@ -70,7 +69,6 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void })
export default function NodesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
@@ -234,18 +232,6 @@ export default function NodesPage() {
const onUpdateNode = useCallback(
(node: NodeRecord) => {
// Agent nodes update through the master-recorded intent (item 20):
// there is no channel to pick, so the confirm is a plain message.
if (node.nodeType === 'agent') {
modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
content: t('pages.nodes.updateAgentConfirmContent'),
okText: t('update'),
cancelText: t('cancel'),
onOk: () => runUpdate([node.id], false),
});
return;
}
devRef.current = false;
modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
@@ -369,7 +355,6 @@ export default function NodesPage() {
onSelectionChange={setSelectedIds}
onAdd={onAdd}
onMtls={() => setMtlsOpen(true)}
onProbeTasks={() => navigate('/settings#probe')}
onEdit={onEdit}
onDelete={onDelete}
onProbe={onProbe}

Some files were not shown because too many files have changed in this diff Show More