55 Commits
Author SHA1 Message Date
Muse 8e89960474 merge wiring: route agent address lookups through ResolveAgentAddresses (V1.0.1)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-23 00:34:33 +08:00
Muse afb5456b8f merge fix/agent-public-addr: master-observed public IP resolution (V1.0.1) 2026-10-23 00:22:58 +08:00
Muse 36acc4c2b1 merge fix/agent-link-address: agent inbounds export telemetry address (V1.0.1)
# Conflicts:
#	internal/web/service/inbound.go
2026-10-23 00:22:36 +08:00
Muse 7f098c6c4e merge fix/online-lists: agent inbound attribution + node page email lists (V1.0.1) 2026-10-23 00:21:04 +08:00
Muse 942b220f80 merge fix/token-backfill-null: legacy NULL token_plain backfill (V1.0.1) 2026-10-23 00:21:04 +08:00
Muse 7d9a38490d fix(sub): agent-hosted inbounds export the agent's telemetry address, not the 'agent' placeholder
Agent nodes store the create form's placeholder in nodes.address
('agent', port 2053; NodeFormModal.buildPayload). The export fallback
chain treated it as a real node address, so every host-less inbound on
an agent node produced links like vless://...@agent:31324.

SubService now loads agent_nodes bindings alongside nodes (latest
last_seen per node, same rule as the node list) and resolves an
agent-managed node's export address through the single convergence
point agentNodeAddress(nodeID): telemetry IPv4, then IPv6, then ""
so the chain continues to listen/public/request host and can never
emit the placeholder. Host-bound inbounds and traditional nodes are
untouched. GetInboundOptions applies the same substitution via
agentAdvertisedAddresses so the frontend share/QR/WireGuard feed
never sees the placeholder either.
2026-10-22 15:35:21 +00:00
Muse 3df652a67d fix(inbounds): slim list stamps agent-hosted inbounds with the agent node's key
Agent inbounds defined centrally store an empty originNodeGuid, and
inbounds re-homed from a panel node keep the previous host's GUID, so
the inbounds page looked the agent's online set up under a key it is
never filed under. GetInboundsSlim now annotates agent-hosted
inbounds at read time with the node's effective attribution key
(the key SetAgentOnlineClients files onlines under).
2026-10-22 15:33:51 +00:00
Muse c91fb454a8 fix(nodes): per-category client email lists behind the node count chips
NodeView carried only the category counts, so the nodes page chips
could hover to nothing but the category title. GetAll/recountByGuid
now emit online/active/disabled/depleted email lists from the same
bucketing pass as the counts (statuses gain the client email;
the online list is the counted set), and the chips pop the list
over with the inbounds page's popover pattern.
2026-10-22 15:33:46 +00:00
Muse c490bdcd76 fix(nodes): resolve agent public address from master-observed source IP
NAT'd agents (cloud 1:1 EIP) only ever report their private
interface address in heartbeat telemetry, so the node list showed
an unusable RFC1918 address. The master now records the source
address of every authenticated agent request (CF-Connecting-IP,
else first X-Forwarded-For hop, else RemoteAddr; change-only
writes) on the agent row as seen_ipv4/seen_ipv6, and the node
list resolves the displayed pair via service.ResolveAgentAddresses:
public telemetry wins, else a public observed source, else the
reported address as-is. CGNAT 100.64/10 observed addresses are
never promoted. Telemetry values are kept verbatim; resolution
is read-time only, and agents need no changes.
2026-10-22 15:25:08 +00:00
Muse 5c432205ad test(agent):回填回归测试——原生 SQL 造 NULL 老行走真实心跳+install 接口 2026-10-22 14:45:57 +00:00
Muse 8901267867 fix(agent):第 19 条 token 回填守卫兼容 SQL NULL 老行(token_plain IS NULL OR = '') 2026-10-22 14:45:55 +00:00
Muse e310abcba0 merge next-scripts: items 15/16/17/23 (install-agent mirror/reuse/self-check, openrc respawn)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
2026-10-22 21:51:51 +08:00
Muse ced5e69de0 merge next-core: items 14/18/19/20/21/22 (L-UI V1.0.0/A1.0.0) 2026-10-22 21:51:50 +08:00
Muse a82d1e56fa style: 本批新代码 gofmt 对齐(config_test/agent_node) 2026-10-22 13:46:02 +00:00
Muse 7e7ab2ecbd feat(update): 发行 tag 查询支持 XUI_RELEASE_LATEST_URL 覆盖(镜像/沙箱冒充发行源) 2026-10-22 13:33:08 +00:00
Muse d46035708e feat(ui): 第22条 改名 L-UI + 第14/21条 + 第18/19/20条前端
- 第22条 L-UI:登录页品牌名、侧栏 brand-text(收起态 L)、抽屉品牌、
  usePageTitle 默认标题(HTML 标题)、13 个语言文件里用户可见串中的
  3X-UI 字样(更新说明×2、TG 版本行)一并改为 L-UI;仓库/二进制/
  服务名/路径/内部包名不动
- 第18条:formatPanelVersion 对 V/A 系显示版本原样返回(新增单测);
  PanelUpdateModal 与首页动作条改用 display* 字段判更新与显示
- 第14条:首页运行时间卡片删除 Xray 半边与分隔线,只留 OS
- 第21条:节点表列宽 操作190→150/已启用80→64/客户端180→150/
  延迟100→84/心跳120→96、地址列 330;agent 行 IPv4·IPv6 单行
  (浅点分隔、沿用可见性 class),传统行不变
- 第20条:agent 行云朵按钮与移动端菜单改用「更新 Agent」文案;
  NodesPage 对 agent 行用无渠道选择的简单确认(传统行不变);
  schemas 增 agentUpdateState/Error/Target
- 第19条:节点编辑页(agent)拉 /panel/api/agent/install/:id 展示
  完整安装命令(master 可编辑、含 token 时完整命令+复制按钮);
  无 token 时展示无 token 命令+agent.json 路径提示,不报错不空白
- i18n 新增 4 键(updateAgent/updateAgentConfirmContent/
  agentInstallCommand/agentTokenNotStored)补齐全部 13 个语言文件
  (zh-CN 中文、其余英文),tsc 干净、panel-version 测试全过、
  i18n 失败仍为既有 forwarding 键集问题(第10条)零新增
2026-10-22 13:30:57 +00:00
Muse 2596880327 feat(update): 第20条 主控面板一键自更新(Go 原生换名 + systemd 重启)
- PanelUpdateInfo 增 displayCurrentVersion/displayLatestVersion/
  displayUpdateAvailable:V 系按个位进位段比较,大于当前才算新版;
  原 currentVersion/latestVersion/updateAvailable 语义不动(节点页
  与传统节点继续用)
- 稳定渠道自更新改 Go 原生路径:查最新 tag→按架构下载发行资产→
  sha256 旁车校验→原子换名(旧二进制留 .bak)→写既有状态文件
  (与 update.sh 同形状,前端轮询协议不变)→脱离进程 systemctl
  restart 本单元(单元名 XUI_SERVICE_NAME 覆盖 / /proc/self/cgroup
  解析 / 回退 x-ui);dev 渠道保留 update.sh 原路径
- 仅 systemd 环境启用(/run/systemd/system 存在或
  XUI_SELFUPDATE_SYSTEMD=1 沙箱钩子),否则报错提示手动更新
- 单测:显示版本比较、cgroup 单元名解析、状态文件往返、systemd
  门拦截
2026-10-22 13:16:35 +00:00
Muse 2365eb138b feat(update): 第20条 agent 一键在线更新(意向→轮询→校验换名→自重启)
- 新增 internal/selfupdate:发行坐标(基址常量+env 覆盖)、Gitea
  latest tag 查询、sha256 旁车校验下载、原子换名(旧二进制留 .bak、
  失败可回滚),含单测
- 主控:UpdatePanels 对 agent 节点改为记录更新意向(最新 tag 的
  V 系→A 系目标;已达标报错不记意向);意向经现有 GET config 轮询
  捎带下发;心跳收 updateStatus 回报(failed/current 清意向留状态、
  过期 requested_at 忽略),遥测版本达标自动确认 success
- agent:识别意向→比自身 A 版本(已达标报 current 不下载)→按架构
  下载资产+sha256 校验→换名留 .bak→刷一次心跳→stopXray→
  syscall.Exec 自重启;exec 失败回滚 .bak 并报 failed;失败 10 分钟
  内不重下同一意向;旧版全程保持运行
- NodeView 增 agentUpdateState/Error/Target 供节点页可观测
- 单测:selfupdate 换名/校验、agent 换名重启/坏校验保旧版/已达标
  跳过/exec 失败回滚、主控意向记录/已达标跳过、config 捎带意向、
  心跳 failed 清意向、心跳新版本确认 success
2026-10-22 13:14:24 +00:00
Muse 27a5097756 feat(install-agent): 第23条 OpenRC 韧性对齐——supervise-daemon 守护替代 command_background
OpenRC 支原用 command_background 后台化,进程崩溃不自动拉起,与 systemd 支
Restart=on-failure 语义不对齐。生成的 /etc/init.d/x-ui-agent 改用
supervisor=supervise-daemon + respawn_delay=5(对齐 RestartSec=5)+
respawn_max=0/respawn_period=0(不限次,对齐持续重试);pidfile 与
output/error_log 约定原样保留,command/command_args 不动,幂等重跑时
init 脚本整文件重写、旧式 command_background 布局自动迁移。

第 15/16/17 条对 OpenRC 路径复核:下载基址、无参沿用、失败报错、收尾
self_check 与 rc-service status 存活断言均在公共段/现有分支内已达标,
未作改动(由新增 OpenRC fake-root 套件实证)。

验证:既有 systemd fake-root 套件 58/58 零回归;新增 OpenRC 套件
(run-tests-openrc.sh,O1–O5)51/51。改动点仅此一处(+7/-1)。
2026-10-22 13:12:57 +00:00
Muse f18aca9410 test(agent): 遥测 AgentVersion 断言对齐第18条 A 系口径 2026-10-22 13:12:14 +00:00
Muse 29b724dae1 feat(nodes): 第19条 agent token 可恢复保存 + 老节点回填 + 安装命令接口
- AgentNode 新增 TokenPlain(json:"-" 永不序列化):建节点/签发 token
  时一并存明文,用户已明确接受"可恢复保存换便利"的口径
- 回填:findAgent 在 token 通过哈希校验后,若该行尚无明文则把本次
  请求携带的 token 回填存下(带 token_plain='' 守卫的 UPDATE,
  已有明文绝不被请求覆盖;只有面板侧签发流程写新值);鉴权语义不变
- 新增管理端点 GET /panel/api/agent/install/:nodeId 返回
  {nodeId,name,token,hasToken},供编辑页重显完整安装命令;老节点
  未回填时 hasToken=false,前端展示无 token 命令 + 本机路径提示
- 单测覆盖:签发存明文 / 回填后接口可读出完整 token / 校验失败
  绝不回填 / 已有明文不被覆盖
2026-10-22 13:04:33 +00:00
Muse 1edb8f71da feat(version): 第18条 显示版本与内部版本解耦(V/A 双系)
- config 新增 displayVersion 注入变量(默认 1.0.0):GetDisplayVersion/
  GetMasterDisplayVersion(V)/GetAgentDisplayVersion(A),以后升版只改注入值
- ParseDisplayVersion/CompareDisplayVersion:三段数值比较,个位进位规则
  (1.0.9<1.1.0) 天然成立;不可解析(dev 标签/3.7.0-agent.1)返回 ok=false
- 显示位切换:dist 注入 X_UI_CUR_VER、-v、TG 报告 → V 系;
  agent 遥测 agentVersion → A 系;节点行 A 系原样显示、老 agent 沿用
  "agent " 前缀兼容
- 内部流不动:GetBaseVersion/GetPanelVersion 及其比较/订阅/远端状态
  消费方保持原样,传统节点更新判定与徽标行为不变
2026-10-22 13:01:50 +00:00
Muse 8900ef68fc feat(install-agent): 镜像下载(15)+无参沿用agent.json(16)+失败报错与收尾自检(17)
- 下载基址统一 RELEASE_BASE 指向 Gitea 发行 V1.0.0:agent 二进制(原在旧
  tag v3.7.0-agent.1)、双架构 xray zip、geoip/geosite 全部走自建镜像,
  不再依赖 GitHub CDN
- 未传 --master/--token/--name 时逐字段沿用本机 /etc/x-ui/agent.json,
  显式参数优先;日志打明沿用的节点名与主控,绝不打印 token;文件不可
  解析且必填仍缺则明确报错退出
- 每步下载/解压显式判失败(拆掉旧 && 链的静默吞错),下载验非空,前置
  检查 curl/unzip;收尾 self_check 真跑 xray version、验 geo 非空与
  agent 可执行,任一不过报错退出且不写配置不起服务
- fake-root 模拟 9 场景 58 断言全 PASS(报告 NEXT-SCRIPTS-REPORT.md)
2026-10-22 12:54:32 +00:00
Muse e5f39ed20b merge: p13-agent-telemetry (agent 遥测上报+节点页显示+地址纯文本)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
2026-10-22 19:36:50 +08:00
Muse 487b074801 feat(nodes-ui): agent 行地址纯文本显示 IP+更新徽标防误亮(第13条)
地址列: agent 行渲染 ipv4/ipv6 纯文本(两行), 不再是面板 URL 链接;
传统行保持链接。面板版本列: agent 行不参与"有可用更新"判定
(版本比较对 "agent x.y" 字符串走不等回退会恒亮)。
2026-10-22 11:33:58 +00:00
Muse 3536d4174e feat(telemetry): agent 心跳遥测上报+节点列表覆写(第13条)
agent 每 60s 心跳捎带遥测: agentVersion/xrayVersion(按 mtime 缓存重读)/
uptime/最近一次主控请求 RTT/cpu%/mem%/公网 v4+v6(公网优先, ULA/私网兜底);
主控心跳处理按指针语义只存上报字段, 旧 agent 无遥测行为不变;
agent_nodes 加法迁移存遥测, 节点列表对 agent 行覆写真值,
panelVersion 显示 "agent <版本>", NodeView 新增 ipv4/ipv6。
2026-10-22 11:33:56 +00:00
Muse 84d7b85be7 merge: p12-agent-rename (agent 改名 xui-agent+脚本迁移)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
2026-10-22 18:40:28 +08:00
Muse 81d89840e4 fix(agent): 流量上报间隔 60s 改为 10s(第9条)
传统节点由主控秒级轮询,agent 旧默认 60s 才推送一次,所有 agent
入站的流量/在线刷新慢一个数量级(用户真机实证,与具体节点无关)。
defaultTrafficInterval 改为 10s;心跳间隔 60s 不动;注册退避上限
(registrationBackoff 的 60s)与流量无关,不动。原注释称 traffic
piggyback 心跳 tick 与实现不符(实为独立 ticker),一并改正。
2026-10-22 10:25:22 +00:00
Muse ff1204ce2d fix(onlines): agent 在线名单按节点归因键存储(第12条修复A)
reportTraffic 旧实现把所有 agent 的在线名单写死存入 "agent" 键,
而入站页按 originNodeGuid / node:{id} 归因键查找,永远查不到:
agent 入站行永不亮在线,且所有 agent 节点被并进同一键。
改为 InboundService.SetAgentOnlineClients:按节点的 effectiveNodeKey
(有唯一 GUID 用 GUID、否则 node:{id})存入;空名单仍走
ClearNodeOnlineClients 清整棵子树。同步修正 online_store_test 中
断言旧键的用例注释与键名;新增两例服务层测试(双 agent 节点键互不
合并、空上报清空)。
2026-10-22 10:25:15 +00:00
Muse 58cdfe73b0 feat(agent): P12 改名 xui-agent——独立二进制文件+改名覆盖+存量迁移清理
install-agent.sh: AGENT_BIN 改 /usr/local/x-ui/xui-agent,下载走 .new 改名
覆盖(agent 自升级不撞 Text file busy),全程不写主控 x-ui 文件;新增
stop_legacy_agent 只精确匹配旧形态 'x-ui agent' 进程(主控裸 x-ui 无参数、
天然不误杀);生成的 systemd/OpenRC 服务改指 xui-agent。仓库 systemd/
x-ui-agent.service 与 openrc/x-ui-agent( procd ) 同步改指;README/docs 的
agent 手动部署段落位改 xui-agent。Go 代码无硬引用、零改动。
fake-root 模拟(systemd/OpenRC/存量重跑)23/23 PASS,见 P12-REPORT.md。
2026-10-22 09:46:46 +00:00
Muse ac39370879 fix(onlines): 恢复被 P9 掏空的在线状态存储(第11条)
根因: 1035d00 删本地核心时, inbound_node.go 的在线存储层被一并掏空
(GetOnlineClients 写死空/SetNodeOnlineTree 等空函数/SetRemoteTraffic
末尾喂树整块删除), 而节点轮询与 agent 上报两条到达路径完好, 数据
送达即被扔, 客户端/入站在线永远全零。

修复: 把原 xray.Process 上的在线存储(算法/锁/grace 20s 语义逐字
移植)落到 service 包单例 onlineStore(sharedOnlineStore, 对应原
currentXrayProcess 的单一持有语义); 恢复六个 wrapper + 新增
SetNodeActiveInboundTree; setRemoteTrafficLocked 补回 guidShared
与喂树块(原 f727d04 逻辑, process. 换 s.)。

偏离: 本地集在精简主控无本地核心喂养, 仅 mtproto job 折入(该 job
已不被调度, 路径保留无害); GetOnlineClients 对 nil 归一为 []。

测试: online_store_test 9 例(-race 全过: 并集/按GUID/替换/清空/
grace 边界/跨实例共享/并发); SetRemoteTraffic 集成 2 例(传统节点
GUID 树+active 树落库、旧节点 flat 回退); 全量 go test 串行复跑失败集合与基线 9 红逐字一致(零新增);
service 包单跑唯一红为基线既有的 TestGetXrayConfig 红, 其余全过。
2026-10-22 09:19:20 +00:00
Muse f569680182 merge: p93-agent-register (注册退避重试+脚本探测服务化+面板地址预填)
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
2026-10-22 04:55:19 +00:00
Muse 9adffd049c merge: p92-socks-clients (SK5 分配用户+记账+订阅 socks 链接) 2026-10-22 04:55:07 +00:00
Muse 8ca1a4647a feat(sub): emit socks:// links for clients on socks/mixed inbounds
GetLink had no socks-family case, so an attached client's
subscription contained no link for the inbound at all, and
getInboundsBySubId's protocol list omitted socks/mixed entirely, so
such a subscription 404'd when the SK5 inbound was the client's
only one. genSocksLink emits socks://user:pass@host:port with the
same credential pair the core config carries — user is the client
email, pass is the client password, both percent-encoded. The
inbound's own fixed account is an operator credential and is never
emitted.
2026-10-21 22:25:18 +00:00
Muse 0ed6d8a1cf fix(nodes): agent 主控地址预填 origin+基础路径,地址为空时不生成安装命令并提示 2026-10-21 22:05:09 +00:00
Muse dcfaf6ace6 feat(install-agent): 启动前探测主控注册接口(404/连不上即中止),改为 systemd/OpenRC 服务化安装带自启 2026-10-21 22:05:09 +00:00
Muse 7d03749246 feat(agent): 注册失败退避重试不停机,404 时日志提示检查 master_url 基础路径 2026-10-21 22:05:09 +00:00
Muse 5efaaef2c2 feat(xray): emit attached clients as accounts for socks/mixed inbounds
Socks-family inbounds authenticate against settings.accounts, so the
clients attached to them never reached the core: the generated config
carried the settings' "clients" mirror (ignored by xray for these
protocols, passwords and all) and only the inbound's own fixed
account. Emit one account per enabled attached client instead —
user = client email, pass = client password — appended after the
inbound's stored accounts, which keep their order and win name
collisions (managed-forwarding resolution reads the fixed account
from the stored settings, untouched). Drop the clients mirror from
the generated settings, and force auth=password when clients exist,
since accounts are inert under noauth.

Accounting needs no new plumbing: xray-core 26.6.27 reports these
accounts under user>>><account user> counters (probe-verified with
two accounts on both mixed and socks), and the agent snapshot ->
report -> AddTraffic chain already keys client traffic by that exact
string, so making the account name the client email aligns booking
end to end. Clientless socks-family inbounds pass through verbatim.
2026-10-21 22:04:49 +00:00
Muse bea7f4b2b6 feat(clients): allow attaching clients to socks/mixed inbounds
The client form, bulk-add form and the inbound-side attach entry all
filtered attachable inbounds by a protocol whitelist that lacked
socks/mixed (inherited from upstream), so SK5 inbounds could never be
picked as a client's associated inbound even though the backend
accepts the association. Add 'socks' and 'mixed' to all three gates.
The client record's existing password field is the socks credential;
no new credential shape is introduced.
2026-10-21 22:01:30 +00:00
Muse 051a74be80 feat(inbounds): forwarding chip on list rows
Rows whose stored ForwardingSpec is enabled get a volcano chip after
the protocol tags: '转发 → <target remark>' for managed landings
(resolved against the full row set), '转发 → <address>:<port>' for
manual endpoints, '转发 → SK5' when the target row is gone. Text is
ellipsised at 180px with a tooltip so narrow rows never overflow.
Data rides the slim list's existing forwarding field — no API change.
2026-10-21 21:01:45 +00:00
Muse d867b15f4a fix(inbounds): forwarding echo lost in DBInbound — add the missing field
The edit modal seeds from the list row wrapped in DBInbound, whose
cloneProps only copies keys the instance already owns. DBInbound had
no forwarding field, so the stored ForwardingSpec JSON was silently
dropped on every row wrap and rawInboundToFormValues fell back to
direct — saved forwarding compiled and worked, but never echoed back
into the form. Backend/API carried the field correctly all along.

Regression test covers managed/manual/direct through the exact
row -> DBInbound -> form-values chain.
2026-10-21 20:59:36 +00:00
Muse 4da8d58307 fix(install-agent): pkill 无匹配时返回 1 会被 set -e 掐死在启动前,加 || true 2026-10-22 04:35:52 +08:00
Muse 6a880aed09 P9(4b): PANEL-LITE-SPEC 同步本地核心整条路径删除与 MTProto/AmneziaWG 退役 2026-10-21 19:30:53 +00:00
Muse 1035d00fe4 精简版P9(4b): 删除本地核心整条路径(进程管理/buildXrayConfig本地分支/local-only注入/本地核心接口与任务)
- service/xray.go 1741->573行: 只剩 agent 配置构建(GetXrayConfigForAgent/buildAgentXrayConfig=模板+节点入站+egress+转发编译); 删进程生命周期/状态机/RestartXray/StopXray/SetToNeedRestart/GetXrayTraffic/GetOnlineUsers/balancer/TestRoute 与 injectPanelEgress/injectNodeEgresses/injectMtproto/injectAmneziawg/mergeSubscriptionOutbounds
- controller: 43处 SetToNeedRestart 与各 RestartXray 调用清除; 删 balancerStatus/balancerOverride/routeTest/getXrayResult 路由与handler(纯本地核心操作); 前端删 RouteTester 页签与组件
- server service: GetConfigJson 改模板直读; 状态Xray块恒 stopped/空版本; Stop/RestartXrayService 改 no-op(DB导入/恢复流程保留形状)
- setting service: PanelEgressProxyURL/NodeEgressProxyURL 恒直连(桥在本地核心内,已不存在); NodeService.withOutboundBridge 恒直连回退
- runtime: Local 改为全 no-op 桩(DB即真源); NewManager() 去 LocalDeps(APIPort/SetNeedRestart)
- job: 删 XrayTrafficJob/CheckXrayRunningJob/CheckClientIpJob 三文件(共享声明 EventBus/IPWithTimestamp/clientStatsSnapshotMaxClients 迁 shared.go); web.go 不再调度 MtprotoJob/AmneziaWGJob, 删 OnCrash 接线与 stop() 的 sidecar StopAll
- MTProto/AmneziaWG 下线: AddInbound/UpdateInbound 对两协议直接拒绝(存量行保留可删); TG bot 的 restart 命令与在线查询按无本地核心收口
- 测试: GetXrayConfig 系列移植到 agent 构建(finalmask/clients/wireguard/scale); 删退役行为测试(lifecycle/restart/mtproto本地应用/client-ip job/inject系列); 共享测试helper迁 mtproto_test_helpers_test.go 与 job/integration_helpers_test.go
- 既有红(非本轮引入,已在 f6d6520 复现): TestGetXrayConfig_EnabledClientsStillEmitted、TestRouteRegistryContract(P6删 endpoints.ts 遗留)
2026-10-21 18:43:55 +00:00
Muse f6d652019d P9(3): per-inbound forwarding module (amo-style form, dynamic egress compile)
Design as decided 2026-10-21 (replaces the raw-JSON egress editor idea):
the forwarding form lives on the inbound, and the entry agent's egress
entries are compiled from it on every config pull — never snapshotted.

- model: Inbound.Forwarding (JSON text) via AutoMigrate; panel-side
  metadata only, never reaches xray itself
- service/forwarding.go: ForwardingSpec (mode direct/forward; landing
  managed|manual), save-time validation (managed target must exist and
  be a SOCKS-family landing — the fork's SK5 inbound is 'mixed'; no
  self-forward; manual endpoint/protocol sanity), live resolution
  (managed: concrete listen IP wins, else hosting node's address;
  credentials from the target's first account), and the compiler that
  appends one socks/http outbound (tag fwd-<inboundId>) plus a prepended
  inboundTag rule per forwarding inbound
- buildXrayConfig agent path: compile runs after applyAgentEgress, so
  entries ride on the live template (or stored egress override) and
  template edits keep flowing; unresolvable entries skip with a warning
  instead of failing the whole config
- UpdateInbound's field-merge now carries Forwarding (its absence was
  caught by the smoke: adds worked, updates silently kept the old spec)
- GET /panel/api/inbounds/forwardTargets: managed-landing candidates
  (id/remark/tag/listen/port/node name) — credentials never exposed
- frontend: new Forwarding tab in the inbound form (mode, landing type,
  managed picker, manual endpoint with link import incl. base64 form),
  adapter round-trips the spec as a JSON string like settings; en/zh
  strings; generated API types refreshed

Acceptance: unit tests for parse/validate/resolve/compile incl. the
dynamic-recompile proof (template edit + spec edit both reflected), and
an end-to-end smoke (real binary, two agent nodes, all inbound writes
in browser form-urlencoded format): 22/22 PASS — see P9-REPORT.md.
2026-10-21 17:40:42 +00:00
Muse ceca96571d P9(5): restore upstream Telegram bot (settings, backup, notifications)
Per user decision 2026-10-21 (reverses the 2026-10-20 drop-Telegram
call): port the upstream 3x-ui v3.7.0-generation Telegram bot wholesale
instead of rewriting a minimal set, adapted to this fork.

- new internal/web/service/tgbot package (telego): bot lifecycle,
  commands, reports, callback router, send helpers; token validation via
  telego; custom API server URL passes the fork's SSRF sanitizer
  (loopback/private bases fall back to the default endpoint, logged)
- settings: full upstream key set (tgBotEnable/Token/Proxy/APIServer/
  ChatId, tgRunTime, tgBotBackup, tgCpu/tgMemory, tgLang,
  tgEnabledEvents) with getters/setters; settings page gains a Telegram
  tab + sidebar entry; token shown masked, clearTgBotToken honored
- wiring (web.go): stats-notify cron registered from tgRunTime when the
  bot is enabled; CheckHashStorage job; CPU/memory alarm jobs gated on
  tgEnabledEvents + thresholds; login notifications via event bus;
  POST /panel/api/setting/testTgBot and /panel/api/backuptotgbot
- lite adaptation: StatsNotifyJob no longer gates on a local Xray core
  (this panel never runs one) so the periodic report and the scheduled
  DB backup (sendDocument) can actually fire
- translations: upstream TG keys restored across all locales

End-to-end smoke (real binary + fake Telegram API over HTTPS at a
public-looking address, browser form wire format): bot starts on
settings change (getMe), testTgBot delivers, panel restart re-arms the
bot, tgRunTime='@every 1m' cron really fires sendDocument carrying the
panel SQLite DB (magic verified) plus the report sendMessage, login
notification delivered, dead-endpoint failure is logged not silent.
13/13 PASS — see P9-REPORT.md.
2026-10-21 17:16:32 +00:00
Muse 5031a70ccb P9(4): remove local-core live-test trio — outbound speed test + egress probe, restart/stop xray endpoints
Per user decision 2026-10-21 (reverses the earlier keep call in
PANEL-LITE-SPEC.md, now amended): the lite panel never runs a local
core, so measuring/probing from the panel host is meaningless.

Backend:
- delete service/outbound probe implementation (probe_http.go and its
  tests) and the TestOutbound/TestOutbounds batch prober; outbound
  traffic accounting (AddTraffic/GetOutboundsTraffic/Reset) unchanged
- drop POST /panel/api/xray/testOutbound + testOutbounds routes and the
  outboundTestUrl setting (get/update plumbing removed; stored rows in
  existing DBs are inert)
- drop POST /panel/api/server/restartXrayService + stopXrayService
  routes/handlers and the node-sync allowlist entry; the service-level
  Stop/RestartXrayService stay for internal import/restore flows
- node-sync allowlist contract test updated to the new inventory

Frontend:
- remove test buttons/mode switch/test-all from outbounds tab, card
  list and table columns; delete TestResultPopover and CountryPill;
  drop the egress/country/latency columns fed by probe results
- remove outboundTestUrl state and save plumbing from useXraySetting
  and the Basics tab field; drop OutboundTestResult schemas
- delete the 15 now-dead i18n keys from all 13 locales (incl. the
  restart/stop keys orphaned since P6/P7 — i18n-dead-keys test green)
- tests: drop the test-URL hook test, re-probe the loopback index
  mapping through Move-to-top, regenerate API types (gen)
2026-10-21 16:35:53 +00:00
Muse 6772c97f8e P8.1: fix browser inbound re-homing — nodeId probe must parse form-encoded bodies
updateInbound detected an explicit nodeId by probing the raw request
body as JSON only (requestHasJSONKey). The panel frontend, however,
posts application/x-www-form-urlencoded by default (http-init.ts
form-encodes every POST without a declared JSON Content-Type), so the
probe missed on every real browser save and UpdateInbound silently kept
the stored assignment — the edit dialog's 部署到 picker never moved
anything, while JSON API callers (and the P8 E2E, which posted JSON)
worked.

requestHasKey is now content-type aware: JSON bodies are probed as
before; urlencoded bodies are parsed with url.ParseQuery, where the
browser's null encoding (a bare "nodeId=") also counts as provided —
it binds as *int(0) and the existing 0->nil normalisation turns it
into the move-to-local target.

Regression tests post the exact browser wire format through the real
handler stack: form move between nodes, form post without nodeId
(assignment kept), form post with nodeId= (move to local, no binding
error), plus the JSON move at HTTP level. The two form-move tests
fail on the unfixed code and pass with the fix.
2026-10-21 15:37:45 +00:00
Muse 7284fa6cf0 P8(2): inbound node reassignment (move) with backend re-homing
An inbound's 部署到 node can now be changed after creation; the row,
clients, client_traffics and subscription output stay untouched (same
inbound id throughout).

Contract: the update payload's nodeId counts only when the key was
explicitly sent — the controller probes the raw JSON body and sets
Inbound.NodeIDProvided; the wire adapter now always emits nodeId (null =
local) so clearing the picker is an explicit move-to-local, while
callers that omit nodeId keep the stored assignment (legacy behaviour).

UpdateInbound resolves the target before the port-conflict check (so
conflicts are scoped to the target node), rejects moves to a missing
node, re-derives auto tags under the target node's prefix, validates
the tag against the target node's namespace, and marks BOTH nodes
dirty: the source node's next reconcile sweeps the old tag (no silent
ghost — the flag persists until a reconcile succeeds, so an offline
side just defers), the target's pushes the inbound. Agent-bound nodes
converge via GetXrayConfigForAgent on the agent's next config poll.
A local side has no reconcile loop, so it is applied directly
post-commit (Del/Add on the local runtime, restart fallback).

Frontend: the deploy-to Select is no longer disabled in edit mode
(protocol stays locked); candidate rules unchanged (online nodes only).

Tests: panel->panel move with reconcile sweep/push assertions,
auto-tag prefix regeneration, agent-config flip in both directions,
omitted-nodeId legacy contract, same-node no-op, missing-node
rejection, node->local move.
2026-10-21 13:49:57 +00:00
Muse 261a1f6787 P8(1): restore homepage overview curves on a memory-only sampling ring
Backend: new overviewMetrics store — a single-tier (2s x 360 = 12min)
in-memory ring fed by ServerService.RefreshStatus for exactly the eight
overview series (cpu/mem/swap/diskUsage/netUp/netDown/tcpCount/udpCount).
No persistence, no gob snapshot, no system-history page: the only surface
is GET /panel/api/server/history/:metric/:bucket restricted to those
keys, which seeds the frontend curves. ~46KB bounded by construction;
the ring starts empty on every panel start.

Frontend: restore useOverviewHistory + the VitalTile/ThroughputCard/
ConnectionsCard sparklines and the IndexPage wiring exactly as they
were before P7 removed them along with the history module.
2026-10-21 13:49:43 +00:00
Muse 60ce5eab98 docs(spec): record scope decision — keep protocol range, IP check/speedtest, ACME, multi-user 2026-10-21 21:09:29 +08:00
Muse f887f47bb9 P7: restore agent node type, agent heartbeat expiry, build-version injection, remove log/history/xray-metrics modules, optional sub port
- nodes: port node-type (traditional/agent) picker back from web-src into
  the embedded frontend; backend annotates NodeView.nodeType from
  agent_nodes bindings; fix agent-node update path (skip reachability
  probe and apiToken requirement for agent-managed nodes); restore i18n
  keys in all 13 locales; install command now uses the Gitea mirror
- jobs: expire agent-bound nodes whose agent_nodes.last_seen is older
  than max(3x heartbeat interval, 120s) = 180s; last_seen=0 (never
  registered) is left untouched; transitions reuse UpdateHeartbeat +
  node event broadcast
- config: GetBaseVersion honors ldflags-injected buildVersion; release
  builds inject 3.7.0-agent.1 so the panel no longer reports a phantom
  update against the Gitea release tag
- logger/server: remove the three log viewers (panel/xray/amneziawg),
  their API endpoints and the 10240-entry in-memory log buffer; file
  logging via lumberjack unchanged; tests assert via a new optional
  logger hook instead of the removed buffer
- server: remove system history and xray metrics modules end to end
  (endpoints, sampling appends in RefreshStatus, per-minute persist
  cron, gob snapshot/restore, XrayMetricsService); realtime status and
  per-node history are untouched
- settings: subPort may be 0/unset - subscription links are generated
  without a port while the subscription service keeps binding the
  default 2096; the settings form declares the cleared state via
  onNumber's new onClear option instead of snapping back to 1
2026-10-21 06:44:00 +00:00
Rogue 2c85f85549 精简版第六轮补裁(P6): 摘除分组/API文档死入口与首页Xray状态徽标及重启/停止按钮, 清理死i18n键 2026-10-20 19:25:29 +00:00
Muse 0f0335e943 修复二开 agent 流量增量丢失: 基线改上报确认后推进并落盘持久化
- xray 新增快照/确认接口 GetTrafficSnapshot/CommitTrafficSnapshot/
  RestoreTrafficBaseline, 与面板 GetTraffic 的 StatsLastValues 完全隔离
- agent 改走快照: 仅主控 HTTP 200 后才推进基线, 失败轮增量由累计
  计数做差在下轮自动补齐; 已确认基线原子落盘, agent 重启恢复续差
- 核心重启计数归零的从零规则保留; Onlines 仍每轮新鲜采集直报
- 新增 11 个测试覆盖断网恢复/连续失败补齐/重启恢复/计数归零/
  面板语义隔离, xray 与 agent 两包全绿
2026-10-20 15:58:38 +00:00
Muse 4828f946b1 精简版第四轮补裁(P4): 页面层摘除 + Xray更新端点下线 + 本机核心监管下线
页面层(用户真机截图发现前两轮漏裁):
- 设置: 摘除 Telegram机器人/邮件菜单与页面, 及页内残留入口(通知页/外部流量/LDAP/2FA设置页)
- Xray配置: 摘除负载均衡/DNS/高级配置菜单与页面; 基础配置(出站+路由)完好
- 总览: 摘除 Xray更新弹窗(VersionModal+GeodataSection)全部入口, 版本号改为静态展示
- 13个locale同步摘除461个已删功能专属翻译键(逐键核查前后端引用为零; i18n-dead-keys测试全绿)

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

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

注: TestGetXrayConfig_EnabledClientsStillEmitted 在基线945b3a5同样失败(既有问题, 非本轮引入)
2026-10-20 14:58:05 +00:00
277 changed files with 15818 additions and 41622 deletions
+8 -5
View File
@@ -24,13 +24,16 @@
- 安全设置:双重验证(2FA,用户不用) - 安全设置:双重验证(2FA,用户不用)
- 邮件模块(SMTP 发送:到期/流量提醒等,用户不用) - 邮件模块(SMTP 发送:到期/流量提醒等,用户不用)
- 分组功能(客户端归类标签 + 按组批量操作/聚合订阅;用户从未用过,裁功能不影响已建客户端与订阅) - 分组功能(客户端归类标签 + 按组批量操作/聚合订阅;用户从未用过,裁功能不影响已建客户端与订阅)
- 本地核心实测三件套(P9 第 4 条,用户 2026-10-21 晚改口):出站测速(临时 xray 实例批量探测 `testOutbound(s)` 接口 + 前端测试按钮/模式切换/结果弹层)、出口探测(同一动作的 egress IP/国家展示)、重启/停止核心接口(`restartXrayService`/`stopXrayService`,与首页已摘按钮同族);出站与路由的模板编辑(增删改存)保留不动
- 本地核心整条路径(P9 第 4b 条):进程管理/状态机、buildXrayConfig 本地分支与 local-only 注入(panel/node egress 桥、订阅出站合并、mtproto/amneziawg 桥)、本地核心专属接口(balancer 状态/override、routeTest、getXrayResult)与前端 RouteTester、本地核心三个 job(流量采集/存活看门狗/客户端 IP 限制)已整条删除;配置构建只剩 agent 一条线(模板 + 节点入站 + egress + 转发编译)。MTProto/AmneziaWG 随之退役:新建/编辑直接拒绝,存量行保留在库中可删除、不再被服务。金标准验证:同一种子库删除前后三台 agent 的生成配置逐字一致
## 待用户确认 ## 范围确认(2026-10-21 用户拍板:四项全部保留,不裁)
- 协议范围:是否只留 VLESS + SS2022,裁其余协议模板(VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等)——建议裁 - 协议范围:保留全部协议模板(VLESS、SS2022、VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等),不收窄
- IP 检查 / 测速类后台任务——建议裁 - IP 检查 / 测速类后台任务:保留 → **2026-10-21 晚用户改口推翻本项**:出站测速 + 出口探测(同一动作,测的是主控本地网络位置;精简主控不跑本机核心、无本地出站,测速结果无意义)连同重启/停止核心控件与接口,已于 P9 第 4 条摘除。其余三项(协议范围 / ACME / 多用户)保留不变,不要再翻出来问。
- 面板内置 ACME 签证书(走 CF Tunnel,不直签)——建议裁 - 面板内置 ACME 签证书:保留
- 多用户功能(单管理员使用)——建议裁 - 多用户功能:保留
- 用户理由:这四块以静态代码为主,裁掉省不了多少内存。此节关闭,后续轮次不要再翻出来问。
## 修复(非裁剪,已定) ## 修复(非裁剪,已定)
+2 -2
View File
@@ -45,8 +45,8 @@ cp geoip.dat geosite.dat /usr/local/x-ui/bin/
```bash ```bash
mkdir -p /usr/local/x-ui/bin /etc/x-ui mkdir -p /usr/local/x-ui/bin /etc/x-ui
# 1. 放同一个 x-ui 二进制(与主控同版本) # 1. 放同一个二进制(与主控同版本),在节点上改名为 xui-agent(与主控进程区分、避免同机覆盖)
cp x-ui-linux-amd64 /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui cp x-ui-linux-amd64 /usr/local/x-ui/xui-agent && chmod +x /usr/local/x-ui/xui-agent
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,需手动放置) # 2. 放 xray 二进制与 geo 文件(agent 不负责下载,需手动放置)
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
+2 -2
View File
@@ -37,8 +37,8 @@ curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
```bash ```bash
mkdir -p /usr/local/x-ui/bin /etc/x-ui mkdir -p /usr/local/x-ui/bin /etc/x-ui
# 1. 放 x-ui 二进制(agent 补丁版,与主控同版本) # 1. 放 x-ui 二进制(agent 补丁版,与主控同版本),在节点上改名为 xui-agent(与主控进程区分)
cp x-ui /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui cp x-ui /usr/local/x-ui/xui-agent && chmod +x /usr/local/x-ui/xui-agent
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,手动放置) # 2. 放 xray 二进制与 geo 文件(agent 不负责下载,手动放置)
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
+2 -3
View File
@@ -10,7 +10,7 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "npm run gen:api && vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "oxlint src tools", "lint": "oxlint src tools",
"lint:fix": "oxlint --fix src tools", "lint:fix": "oxlint --fix src tools",
@@ -22,8 +22,7 @@
"test:watch": "vitest", "test:watch": "vitest",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build", "build-storybook": "storybook build",
"gen": "npm run gen:zod && npm run gen:api", "gen": "npm run gen:zod",
"gen:api": "node scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen", "gen:zod": "cd .. && go run ./tools/openapigen",
"prepare": "cd .. && husky frontend/.husky || true" "prepare": "cd .. && husky frontend/.husky || true"
}, },
File diff suppressed because it is too large Load Diff
-246
View File
@@ -1,246 +0,0 @@
#!/usr/bin/env node
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sections } from '../src/pages/api-docs/endpoints.ts';
import { EXAMPLES } from '../src/generated/examples.ts';
import { SCHEMAS } from '../src/generated/schemas.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outPath = join(__dirname, '..', 'public', 'openapi.json');
const PANEL_VERSION = process.env.X_UI_VERSION || '3.x';
const SECURITY_SCHEMES = {
bearerAuth: {
type: 'http',
scheme: 'bearer',
description: 'API token from Settings → Security → API Token. Send as `Authorization: Bearer <token>`.',
},
cookieAuth: {
type: 'apiKey',
in: 'cookie',
name: '3x-ui',
description: 'Session cookie set by POST /login. Browser-only.',
},
};
function ginPathToOpenApi(path) {
return path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
}
function extractPathParams(openApiPath) {
const params = [];
const re = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
let m;
while ((m = re.exec(openApiPath)) !== null) params.push(m[1]);
return params;
}
function mapType(t) {
const v = String(t || '').toLowerCase();
if (v.endsWith('[]')) return 'array';
if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
if (v === 'float' || v === 'double') return 'number';
if (v === 'boolean' || v === 'bool') return 'boolean';
if (v === 'array') return 'array';
if (v === 'object') return 'object';
return 'string';
}
function schemaFromType(t) {
const v = String(t || '').toLowerCase();
if (v.endsWith('[]')) {
const itemType = v.slice(0, -2);
return { type: 'array', items: { type: mapType(itemType) } };
}
return { type: mapType(v) };
}
function tryParseJson(raw) {
if (typeof raw !== 'string') return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
function paramToOpenApi(p) {
const out = {
name: p.name,
in: p.in,
required: p.in === 'path' ? true : !p.optional,
description: p.desc || '',
schema: schemaFromType(p.type),
};
if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
return out;
}
function buildOperation(ep, tag) {
const op = {
tags: [tag],
summary: ep.summary || '',
operationId: `${ep.method.toLowerCase()}_${ep.path.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_|_$/g, '')}`,
};
if (ep.description) op.description = ep.description;
if (ep.deprecated) op.deprecated = true;
const params = [];
const bodyParams = [];
for (const p of ep.params || []) {
if (p.in === 'body') {
bodyParams.push(p);
} else if (p.in === 'path' || p.in === 'query' || p.in === 'header') {
params.push(paramToOpenApi(p));
}
}
const openApiPath = ginPathToOpenApi(ep.path);
const declared = new Set(params.filter((x) => x.in === 'path').map((x) => x.name));
for (const name of extractPathParams(openApiPath)) {
if (declared.has(name)) continue;
params.push({
name,
in: 'path',
required: true,
description: '',
schema: { type: 'string' },
});
}
if (params.length > 0) op.parameters = params;
if (ep.body || bodyParams.length > 0) {
const example = tryParseJson(ep.body);
const properties = {};
const required = [];
for (const bp of bodyParams) {
properties[bp.name] = {
...schemaFromType(bp.type),
description: bp.desc || '',
};
if (!bp.optional) required.push(bp.name);
}
const schema = bodyParams.length > 0
? { type: 'object', properties, ...(required.length > 0 ? { required } : {}) }
: { type: 'object' };
op.requestBody = {
required: required.length > 0 || bodyParams.length === 0,
content: {
'application/json': {
schema,
...(example !== undefined ? { example } : {}),
},
},
};
}
const responses = {};
let successExample = tryParseJson(ep.response);
let objSchema = {};
if (ep.responseSchema) {
const obj = EXAMPLES[ep.responseSchema];
if (obj === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
}
if (SCHEMAS[ep.responseSchema] === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
}
const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
if (successExample === undefined) {
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
}
}
responses['200'] = {
description: 'Successful response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
obj: objSchema,
},
},
...(successExample !== undefined ? { example: successExample } : {}),
},
},
};
const errExample = tryParseJson(ep.errorResponse);
if (errExample !== undefined || ep.errorStatus) {
const code = String(ep.errorStatus || 400);
responses[code] = {
description: 'Error response',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
},
},
...(errExample !== undefined ? { example: errExample } : {}),
},
},
};
}
op.responses = responses;
return op;
}
function buildSpec() {
const paths = {};
for (const section of sections) {
const tag = section.title;
for (const ep of section.endpoints) {
const openApiPath = ginPathToOpenApi(ep.path);
if (!paths[openApiPath]) paths[openApiPath] = {};
paths[openApiPath][ep.method.toLowerCase()] = buildOperation(ep, tag);
}
}
const tags = sections.map((s) => ({
name: s.title,
description: s.description || '',
}));
return {
openapi: '3.0.3',
info: {
title: '3X-UI Panel API',
version: PANEL_VERSION,
description:
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
},
servers: [
{ url: '/', description: 'Current panel (basePath aware)' },
],
components: {
securitySchemes: SECURITY_SCHEMES,
schemas: SCHEMAS,
},
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
tags,
paths,
};
}
const spec = buildSpec();
writeFileSync(outPath, JSON.stringify(spec, null, 2) + '\n');
const pathCount = Object.keys(spec.paths).length;
let opCount = 0;
for (const ops of Object.values(spec.paths)) opCount += Object.keys(ops).length;
console.log(`[openapi] wrote ${outPath}`);
console.log(`[openapi] paths: ${pathCount}, operations: ${opCount}, tags: ${spec.tags.length}`);
void pathToFileURL;
-1
View File
@@ -36,7 +36,6 @@ export const keys = {
onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const, onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
activeInbounds: () => ['clients', 'activeInbounds'] as const, activeInbounds: () => ['clients', 'activeInbounds'] as const,
lastOnline: () => ['clients', 'lastOnline'] as const, lastOnline: () => ['clients', 'lastOnline'] as const,
groups: () => ['clients', 'groups'] as const,
}, },
xray: { xray: {
root: () => ['xray'] as const, root: () => ['xray'] as const,
+6 -126
View File
@@ -3,64 +3,24 @@ export const EXAMPLES: Record<string, unknown> = {
"AllSetting": { "AllSetting": {
"datepicker": "", "datepicker": "",
"expireDiff": 0, "expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"ipLimitAllowlist": "", "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, "outboundDownThreshold": 1,
"pageSize": 0, "pageSize": 0,
"panelOutbound": "", "panelOutbound": "",
"remarkTemplate": "", "remarkTemplate": "",
"restartXrayOnClientDisable": false, "restartXrayOnClientDisable": false,
"sessionMaxAge": 1, "sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "", "subAnnounce": "",
"subCertFile": "", "subCertFile": "",
"subClashAutoDetect": false, "subClashAutoDetect": false,
"subClashEnable": false, "subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "", "subClashPath": "",
"subClashRules": "",
"subClashURI": "", "subClashURI": "",
"subClashUserAgentRegex": "", "subClashUserAgentRegex": "",
"subDomain": "", "subDomain": "",
"subEnable": false, "subEnable": false,
"subEnableRouting": false,
"subEncrypt": false, "subEncrypt": false,
"subHideSettings": false, "subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonAlwaysArray": false, "subJsonAlwaysArray": false,
"subJsonAutoDetect": false, "subJsonAutoDetect": false,
"subJsonEnable": false, "subJsonEnable": false,
@@ -68,15 +28,13 @@ export const EXAMPLES: Record<string, unknown> = {
"subJsonMux": "", "subJsonMux": "",
"subJsonObservatory": "", "subJsonObservatory": "",
"subJsonPath": "", "subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "", "subJsonURI": "",
"subJsonUserAgentRegex": "", "subJsonUserAgentRegex": "",
"subKeyFile": "", "subKeyFile": "",
"subListen": "", "subListen": "",
"subPath": "", "subPath": "",
"subPort": 1, "subPort": 0,
"subProfileUrl": "", "subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false, "subShowIdentityOnAllLinks": false,
"subSupportUrl": "", "subSupportUrl": "",
"subThemeDir": "", "subThemeDir": "",
@@ -97,8 +55,6 @@ export const EXAMPLES: Record<string, unknown> = {
"timeLocation": "", "timeLocation": "",
"trafficDiff": 0, "trafficDiff": 0,
"trustedProxyCIDRs": "", "trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"warpUpdateInterval": 0, "warpUpdateInterval": 0,
"webBasePath": "", "webBasePath": "",
"webCertFile": "", "webCertFile": "",
@@ -110,71 +66,28 @@ export const EXAMPLES: Record<string, unknown> = {
"AllSettingView": { "AllSettingView": {
"datepicker": "", "datepicker": "",
"expireDiff": 0, "expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"hasApiToken": false, "hasApiToken": false,
"hasLdapPassword": false,
"hasNordSecret": false, "hasNordSecret": false,
"hasSmtpPassword": false,
"hasTgBotToken": false, "hasTgBotToken": false,
"hasTwoFactorToken": false,
"hasWarpSecret": false, "hasWarpSecret": false,
"ipLimitAllowlist": "", "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, "outboundDownThreshold": 1,
"pageSize": 0, "pageSize": 0,
"panelOutbound": "", "panelOutbound": "",
"remarkTemplate": "", "remarkTemplate": "",
"restartXrayOnClientDisable": false, "restartXrayOnClientDisable": false,
"sessionMaxAge": 1, "sessionMaxAge": 1,
"smtpCpu": 0,
"smtpEnable": false,
"smtpEnabledEvents": "",
"smtpEncryptionType": "",
"smtpFrom": "",
"smtpFromName": "",
"smtpHost": "",
"smtpMemory": 0,
"smtpPassword": "",
"smtpPort": 1,
"smtpTo": "",
"smtpUsername": "",
"subAnnounce": "", "subAnnounce": "",
"subCertFile": "", "subCertFile": "",
"subClashAutoDetect": false, "subClashAutoDetect": false,
"subClashEnable": false, "subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "", "subClashPath": "",
"subClashRules": "",
"subClashURI": "", "subClashURI": "",
"subClashUserAgentRegex": "", "subClashUserAgentRegex": "",
"subDomain": "", "subDomain": "",
"subEnable": false, "subEnable": false,
"subEnableRouting": false,
"subEncrypt": false, "subEncrypt": false,
"subHideSettings": false, "subHideSettings": false,
"subIncyEnableRouting": false,
"subIncyRoutingRules": "",
"subJsonAlwaysArray": false, "subJsonAlwaysArray": false,
"subJsonAutoDetect": false, "subJsonAutoDetect": false,
"subJsonEnable": false, "subJsonEnable": false,
@@ -182,15 +95,13 @@ export const EXAMPLES: Record<string, unknown> = {
"subJsonMux": "", "subJsonMux": "",
"subJsonObservatory": "", "subJsonObservatory": "",
"subJsonPath": "", "subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "", "subJsonURI": "",
"subJsonUserAgentRegex": "", "subJsonUserAgentRegex": "",
"subKeyFile": "", "subKeyFile": "",
"subListen": "", "subListen": "",
"subPath": "", "subPath": "",
"subPort": 1, "subPort": 0,
"subProfileUrl": "", "subProfileUrl": "",
"subRoutingRules": "",
"subShowIdentityOnAllLinks": false, "subShowIdentityOnAllLinks": false,
"subSupportUrl": "", "subSupportUrl": "",
"subThemeDir": "", "subThemeDir": "",
@@ -211,8 +122,6 @@ export const EXAMPLES: Record<string, unknown> = {
"timeLocation": "", "timeLocation": "",
"trafficDiff": 0, "trafficDiff": 0,
"trustedProxyCIDRs": "", "trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"warpUpdateInterval": 0, "warpUpdateInterval": 0,
"webBasePath": "", "webBasePath": "",
"webCertFile": "", "webCertFile": "",
@@ -221,26 +130,6 @@ export const EXAMPLES: Record<string, unknown> = {
"webListen": "", "webListen": "",
"webPort": 1 "webPort": 1
}, },
"AmneziaWGLogs": {
"events": [
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
],
"peers": [
{
"allowedIPs": "10.8.1.2/32",
"down": 4194304,
"email": "peer@example.com",
"endpoint": "203.0.113.9:51820",
"handshake": 1735732800000,
"inboundId": 1,
"interface": "awg1",
"online": true,
"tag": "inbound-51820",
"up": 1048576
}
],
"running": true
},
"ApiToken": { "ApiToken": {
"createdAt": 0, "createdAt": 0,
"enabled": false, "enabled": false,
@@ -530,6 +419,7 @@ export const EXAMPLES: Record<string, unknown> = {
"enable": true, "enable": true,
"expiryTime": 0, "expiryTime": 0,
"fallbackParent": null, "fallbackParent": null,
"forwarding": "",
"id": 1, "id": 1,
"lastTrafficResetTime": 0, "lastTrafficResetTime": 0,
"listen": "", "listen": "",
@@ -648,9 +538,10 @@ export const EXAMPLES: Record<string, unknown> = {
"" ""
], ],
"name": "", "name": "",
"nodeType": "traditional",
"outboundTag": "", "outboundTag": "",
"pinnedCertSha256": "", "pinnedCertSha256": "",
"port": 1, "port": 0,
"remark": "", "remark": "",
"scheme": "http", "scheme": "http",
"tlsVerifyMode": "verify" "tlsVerifyMode": "verify"
@@ -683,6 +574,7 @@ export const EXAMPLES: Record<string, unknown> = {
"name": "edge-1", "name": "edge-1",
"netDown": 1048576, "netDown": 1048576,
"netUp": 2097152, "netUp": 2097152,
"nodeType": "traditional",
"onlineCount": 5, "onlineCount": 5,
"outboundTag": "direct", "outboundTag": "direct",
"panelVersion": "v3.x.x", "panelVersion": "v3.x.x",
@@ -713,18 +605,6 @@ export const EXAMPLES: Record<string, unknown> = {
"runId": "1735689600123456789", "runId": "1735689600123456789",
"state": "success" "state": "success"
}, },
"PeerActivity": {
"allowedIPs": "10.8.1.2/32",
"down": 4194304,
"email": "peer@example.com",
"endpoint": "203.0.113.9:51820",
"handshake": 1735732800000,
"inboundId": 1,
"interface": "awg1",
"online": true,
"tag": "inbound-51820",
"up": 1048576
},
"ProbeResultUI": { "ProbeResultUI": {
"cpuPct": 12.5, "cpuPct": 12.5,
"error": "", "error": "",
+23 -481
View File
@@ -9,83 +9,9 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"externalTrafficInformEnable": {
"type": "boolean"
},
"externalTrafficInformURI": {
"type": "string"
},
"ipLimitAllowlist": { "ipLimitAllowlist": {
"type": "string" "type": "string"
}, },
"ldapAutoCreate": {
"type": "boolean"
},
"ldapAutoDelete": {
"type": "boolean"
},
"ldapBaseDN": {
"type": "string"
},
"ldapBindDN": {
"type": "string"
},
"ldapDefaultExpiryDays": {
"minimum": 0,
"type": "integer"
},
"ldapDefaultLimitIP": {
"minimum": 0,
"type": "integer"
},
"ldapDefaultTotalGB": {
"minimum": 0,
"type": "integer"
},
"ldapEnable": {
"type": "boolean"
},
"ldapFlagField": {
"type": "string"
},
"ldapHost": {
"type": "string"
},
"ldapInboundTags": {
"type": "string"
},
"ldapInsecureSkipVerify": {
"type": "boolean"
},
"ldapInvertFlag": {
"type": "boolean"
},
"ldapPassword": {
"type": "string"
},
"ldapPort": {
"maximum": 65535,
"minimum": 0,
"type": "integer"
},
"ldapSyncCron": {
"type": "string"
},
"ldapTruthyValues": {
"type": "string"
},
"ldapUseTLS": {
"type": "boolean"
},
"ldapUserAttr": {
"type": "string"
},
"ldapUserFilter": {
"type": "string"
},
"ldapVlessField": {
"type": "string"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -110,48 +36,6 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"smtpCpu": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpEnable": {
"type": "boolean"
},
"smtpEnabledEvents": {
"type": "string"
},
"smtpEncryptionType": {
"type": "string"
},
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": {
"type": "string"
},
"smtpMemory": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpPassword": {
"type": "string"
},
"smtpPort": {
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"smtpTo": {
"type": "string"
},
"smtpUsername": {
"type": "string"
},
"subAnnounce": { "subAnnounce": {
"type": "string" "type": "string"
}, },
@@ -164,15 +48,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashEnable": { "subClashEnable": {
"type": "boolean" "type": "boolean"
}, },
"subClashEnableRouting": {
"type": "boolean"
},
"subClashPath": { "subClashPath": {
"type": "string" "type": "string"
}, },
"subClashRules": {
"type": "string"
},
"subClashURI": { "subClashURI": {
"type": "string" "type": "string"
}, },
@@ -185,21 +63,12 @@ export const SCHEMAS: Record<string, unknown> = {
"subEnable": { "subEnable": {
"type": "boolean" "type": "boolean"
}, },
"subEnableRouting": {
"type": "boolean"
},
"subEncrypt": { "subEncrypt": {
"type": "boolean" "type": "boolean"
}, },
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subIncyEnableRouting": {
"type": "boolean"
},
"subIncyRoutingRules": {
"type": "string"
},
"subJsonAlwaysArray": { "subJsonAlwaysArray": {
"type": "boolean" "type": "boolean"
}, },
@@ -221,9 +90,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonPath": { "subJsonPath": {
"type": "string" "type": "string"
}, },
"subJsonRules": {
"type": "string"
},
"subJsonURI": { "subJsonURI": {
"type": "string" "type": "string"
}, },
@@ -240,16 +106,14 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string" "type": "string"
}, },
"subPort": { "subPort": {
"description": "SubPort 0 means \"no explicit port\": subscription links are generated\nwithout a port (standard scheme port behind a reverse proxy) while the\nsub server still binds the default port. See SettingService.BuildSubURIBase\nand the sub server bootstrap for the two sides of that split.",
"maximum": 65535, "maximum": 65535,
"minimum": 1, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": { "subShowIdentityOnAllLinks": {
"type": "boolean" "type": "boolean"
}, },
@@ -318,12 +182,6 @@ export const SCHEMAS: Record<string, unknown> = {
"trustedProxyCIDRs": { "trustedProxyCIDRs": {
"type": "string" "type": "string"
}, },
"twoFactorEnable": {
"type": "boolean"
},
"twoFactorToken": {
"type": "string"
},
"warpUpdateInterval": { "warpUpdateInterval": {
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
@@ -352,64 +210,24 @@ export const SCHEMAS: Record<string, unknown> = {
"required": [ "required": [
"datepicker", "datepicker",
"expireDiff", "expireDiff",
"externalTrafficInformEnable",
"externalTrafficInformURI",
"ipLimitAllowlist", "ipLimitAllowlist",
"ldapAutoCreate",
"ldapAutoDelete",
"ldapBaseDN",
"ldapBindDN",
"ldapDefaultExpiryDays",
"ldapDefaultLimitIP",
"ldapDefaultTotalGB",
"ldapEnable",
"ldapFlagField",
"ldapHost",
"ldapInboundTags",
"ldapInsecureSkipVerify",
"ldapInvertFlag",
"ldapPassword",
"ldapPort",
"ldapSyncCron",
"ldapTruthyValues",
"ldapUseTLS",
"ldapUserAttr",
"ldapUserFilter",
"ldapVlessField",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
"remarkTemplate", "remarkTemplate",
"restartXrayOnClientDisable", "restartXrayOnClientDisable",
"sessionMaxAge", "sessionMaxAge",
"smtpCpu",
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost",
"smtpMemory",
"smtpPassword",
"smtpPort",
"smtpTo",
"smtpUsername",
"subAnnounce", "subAnnounce",
"subCertFile", "subCertFile",
"subClashAutoDetect", "subClashAutoDetect",
"subClashEnable", "subClashEnable",
"subClashEnableRouting",
"subClashPath", "subClashPath",
"subClashRules",
"subClashURI", "subClashURI",
"subClashUserAgentRegex", "subClashUserAgentRegex",
"subDomain", "subDomain",
"subEnable", "subEnable",
"subEnableRouting",
"subEncrypt", "subEncrypt",
"subHideSettings", "subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonAlwaysArray", "subJsonAlwaysArray",
"subJsonAutoDetect", "subJsonAutoDetect",
"subJsonEnable", "subJsonEnable",
@@ -417,7 +235,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonMux", "subJsonMux",
"subJsonObservatory", "subJsonObservatory",
"subJsonPath", "subJsonPath",
"subJsonRules",
"subJsonURI", "subJsonURI",
"subJsonUserAgentRegex", "subJsonUserAgentRegex",
"subKeyFile", "subKeyFile",
@@ -425,7 +242,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subPath", "subPath",
"subPort", "subPort",
"subProfileUrl", "subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
"subSupportUrl", "subSupportUrl",
"subThemeDir", "subThemeDir",
@@ -446,8 +262,6 @@ export const SCHEMAS: Record<string, unknown> = {
"timeLocation", "timeLocation",
"trafficDiff", "trafficDiff",
"trustedProxyCIDRs", "trustedProxyCIDRs",
"twoFactorEnable",
"twoFactorToken",
"warpUpdateInterval", "warpUpdateInterval",
"webBasePath", "webBasePath",
"webCertFile", "webCertFile",
@@ -467,104 +281,21 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"externalTrafficInformEnable": {
"type": "boolean"
},
"externalTrafficInformURI": {
"type": "string"
},
"hasApiToken": { "hasApiToken": {
"type": "boolean" "type": "boolean"
}, },
"hasLdapPassword": {
"type": "boolean"
},
"hasNordSecret": { "hasNordSecret": {
"type": "boolean" "type": "boolean"
}, },
"hasSmtpPassword": {
"type": "boolean"
},
"hasTgBotToken": { "hasTgBotToken": {
"type": "boolean" "type": "boolean"
}, },
"hasTwoFactorToken": {
"type": "boolean"
},
"hasWarpSecret": { "hasWarpSecret": {
"type": "boolean" "type": "boolean"
}, },
"ipLimitAllowlist": { "ipLimitAllowlist": {
"type": "string" "type": "string"
}, },
"ldapAutoCreate": {
"type": "boolean"
},
"ldapAutoDelete": {
"type": "boolean"
},
"ldapBaseDN": {
"type": "string"
},
"ldapBindDN": {
"type": "string"
},
"ldapDefaultExpiryDays": {
"minimum": 0,
"type": "integer"
},
"ldapDefaultLimitIP": {
"minimum": 0,
"type": "integer"
},
"ldapDefaultTotalGB": {
"minimum": 0,
"type": "integer"
},
"ldapEnable": {
"type": "boolean"
},
"ldapFlagField": {
"type": "string"
},
"ldapHost": {
"type": "string"
},
"ldapInboundTags": {
"type": "string"
},
"ldapInsecureSkipVerify": {
"type": "boolean"
},
"ldapInvertFlag": {
"type": "boolean"
},
"ldapPassword": {
"type": "string"
},
"ldapPort": {
"maximum": 65535,
"minimum": 0,
"type": "integer"
},
"ldapSyncCron": {
"type": "string"
},
"ldapTruthyValues": {
"type": "string"
},
"ldapUseTLS": {
"type": "boolean"
},
"ldapUserAttr": {
"type": "string"
},
"ldapUserFilter": {
"type": "string"
},
"ldapVlessField": {
"type": "string"
},
"outboundDownThreshold": { "outboundDownThreshold": {
"maximum": 100, "maximum": 100,
"minimum": 1, "minimum": 1,
@@ -589,48 +320,6 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 1, "minimum": 1,
"type": "integer" "type": "integer"
}, },
"smtpCpu": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpEnable": {
"type": "boolean"
},
"smtpEnabledEvents": {
"type": "string"
},
"smtpEncryptionType": {
"type": "string"
},
"smtpFrom": {
"type": "string"
},
"smtpFromName": {
"type": "string"
},
"smtpHost": {
"type": "string"
},
"smtpMemory": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"smtpPassword": {
"type": "string"
},
"smtpPort": {
"maximum": 65535,
"minimum": 1,
"type": "integer"
},
"smtpTo": {
"type": "string"
},
"smtpUsername": {
"type": "string"
},
"subAnnounce": { "subAnnounce": {
"type": "string" "type": "string"
}, },
@@ -643,15 +332,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashEnable": { "subClashEnable": {
"type": "boolean" "type": "boolean"
}, },
"subClashEnableRouting": {
"type": "boolean"
},
"subClashPath": { "subClashPath": {
"type": "string" "type": "string"
}, },
"subClashRules": {
"type": "string"
},
"subClashURI": { "subClashURI": {
"type": "string" "type": "string"
}, },
@@ -664,21 +347,12 @@ export const SCHEMAS: Record<string, unknown> = {
"subEnable": { "subEnable": {
"type": "boolean" "type": "boolean"
}, },
"subEnableRouting": {
"type": "boolean"
},
"subEncrypt": { "subEncrypt": {
"type": "boolean" "type": "boolean"
}, },
"subHideSettings": { "subHideSettings": {
"type": "boolean" "type": "boolean"
}, },
"subIncyEnableRouting": {
"type": "boolean"
},
"subIncyRoutingRules": {
"type": "string"
},
"subJsonAlwaysArray": { "subJsonAlwaysArray": {
"type": "boolean" "type": "boolean"
}, },
@@ -700,9 +374,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonPath": { "subJsonPath": {
"type": "string" "type": "string"
}, },
"subJsonRules": {
"type": "string"
},
"subJsonURI": { "subJsonURI": {
"type": "string" "type": "string"
}, },
@@ -719,16 +390,14 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string" "type": "string"
}, },
"subPort": { "subPort": {
"description": "SubPort 0 means \"no explicit port\": subscription links are generated\nwithout a port (standard scheme port behind a reverse proxy) while the\nsub server still binds the default port. See SettingService.BuildSubURIBase\nand the sub server bootstrap for the two sides of that split.",
"maximum": 65535, "maximum": 65535,
"minimum": 1, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"subProfileUrl": { "subProfileUrl": {
"type": "string" "type": "string"
}, },
"subRoutingRules": {
"type": "string"
},
"subShowIdentityOnAllLinks": { "subShowIdentityOnAllLinks": {
"type": "boolean" "type": "boolean"
}, },
@@ -797,12 +466,6 @@ export const SCHEMAS: Record<string, unknown> = {
"trustedProxyCIDRs": { "trustedProxyCIDRs": {
"type": "string" "type": "string"
}, },
"twoFactorEnable": {
"type": "boolean"
},
"twoFactorToken": {
"type": "string"
},
"warpUpdateInterval": { "warpUpdateInterval": {
"minimum": 0, "minimum": 0,
"type": "integer" "type": "integer"
@@ -831,71 +494,28 @@ export const SCHEMAS: Record<string, unknown> = {
"required": [ "required": [
"datepicker", "datepicker",
"expireDiff", "expireDiff",
"externalTrafficInformEnable",
"externalTrafficInformURI",
"hasApiToken", "hasApiToken",
"hasLdapPassword",
"hasNordSecret", "hasNordSecret",
"hasSmtpPassword",
"hasTgBotToken", "hasTgBotToken",
"hasTwoFactorToken",
"hasWarpSecret", "hasWarpSecret",
"ipLimitAllowlist", "ipLimitAllowlist",
"ldapAutoCreate",
"ldapAutoDelete",
"ldapBaseDN",
"ldapBindDN",
"ldapDefaultExpiryDays",
"ldapDefaultLimitIP",
"ldapDefaultTotalGB",
"ldapEnable",
"ldapFlagField",
"ldapHost",
"ldapInboundTags",
"ldapInsecureSkipVerify",
"ldapInvertFlag",
"ldapPassword",
"ldapPort",
"ldapSyncCron",
"ldapTruthyValues",
"ldapUseTLS",
"ldapUserAttr",
"ldapUserFilter",
"ldapVlessField",
"outboundDownThreshold", "outboundDownThreshold",
"pageSize", "pageSize",
"panelOutbound", "panelOutbound",
"remarkTemplate", "remarkTemplate",
"restartXrayOnClientDisable", "restartXrayOnClientDisable",
"sessionMaxAge", "sessionMaxAge",
"smtpCpu",
"smtpEnable",
"smtpEnabledEvents",
"smtpEncryptionType",
"smtpFrom",
"smtpFromName",
"smtpHost",
"smtpMemory",
"smtpPassword",
"smtpPort",
"smtpTo",
"smtpUsername",
"subAnnounce", "subAnnounce",
"subCertFile", "subCertFile",
"subClashAutoDetect", "subClashAutoDetect",
"subClashEnable", "subClashEnable",
"subClashEnableRouting",
"subClashPath", "subClashPath",
"subClashRules",
"subClashURI", "subClashURI",
"subClashUserAgentRegex", "subClashUserAgentRegex",
"subDomain", "subDomain",
"subEnable", "subEnable",
"subEnableRouting",
"subEncrypt", "subEncrypt",
"subHideSettings", "subHideSettings",
"subIncyEnableRouting",
"subIncyRoutingRules",
"subJsonAlwaysArray", "subJsonAlwaysArray",
"subJsonAutoDetect", "subJsonAutoDetect",
"subJsonEnable", "subJsonEnable",
@@ -903,7 +523,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subJsonMux", "subJsonMux",
"subJsonObservatory", "subJsonObservatory",
"subJsonPath", "subJsonPath",
"subJsonRules",
"subJsonURI", "subJsonURI",
"subJsonUserAgentRegex", "subJsonUserAgentRegex",
"subKeyFile", "subKeyFile",
@@ -911,7 +530,6 @@ export const SCHEMAS: Record<string, unknown> = {
"subPath", "subPath",
"subPort", "subPort",
"subProfileUrl", "subProfileUrl",
"subRoutingRules",
"subShowIdentityOnAllLinks", "subShowIdentityOnAllLinks",
"subSupportUrl", "subSupportUrl",
"subThemeDir", "subThemeDir",
@@ -932,8 +550,6 @@ export const SCHEMAS: Record<string, unknown> = {
"timeLocation", "timeLocation",
"trafficDiff", "trafficDiff",
"trustedProxyCIDRs", "trustedProxyCIDRs",
"twoFactorEnable",
"twoFactorToken",
"warpUpdateInterval", "warpUpdateInterval",
"webBasePath", "webBasePath",
"webCertFile", "webCertFile",
@@ -944,36 +560,6 @@ export const SCHEMAS: Record<string, unknown> = {
], ],
"type": "object" "type": "object"
}, },
"AmneziaWGLogs": {
"description": "AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live\nper-peer activity of every running embedded interface, plus the panel's\nown recent AmneziaWG lifecycle log lines that explain a peer being absent\nfrom Peers at all.",
"properties": {
"events": {
"example": [
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
],
"items": {
"type": "string"
},
"type": "array"
},
"peers": {
"items": {
"$ref": "#/components/schemas/PeerActivity"
},
"type": "array"
},
"running": {
"example": true,
"type": "boolean"
}
},
"required": [
"events",
"peers",
"running"
],
"type": "object"
},
"ApiToken": { "ApiToken": {
"properties": { "properties": {
"createdAt": { "createdAt": {
@@ -2061,6 +1647,10 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "FallbackParent is populated by the API layer when this inbound is\nattached as a fallback child of a VLESS/Trojan TCP-TLS master.\nThe frontend uses it to rewrite client-share links so they advertise\nthe master's externally reachable endpoint instead of the child's\nloopback listen. Not persisted.", "description": "FallbackParent is populated by the API layer when this inbound is\nattached as a fallback child of a VLESS/Trojan TCP-TLS master.\nThe frontend uses it to rewrite client-share links so they advertise\nthe master's externally reachable endpoint instead of the child's\nloopback listen. Not persisted.",
"nullable": true "nullable": true
}, },
"forwarding": {
"description": "Forwarding holds the per-inbound forwarding form as JSON\n(service.ForwardingSpec). Empty means direct egress. Panel-side\nmetadata only: when an agent pulls its config, the entry agent's\noutbounds/routing gain a compiled entry for this inbound — the raw\nstring itself never reaches xray.",
"type": "string"
},
"id": { "id": {
"description": "Unique identifier", "description": "Unique identifier",
"example": 1, "example": 1,
@@ -2171,6 +1761,7 @@ export const SCHEMAS: Record<string, unknown> = {
"down", "down",
"enable", "enable",
"expiryTime", "expiryTime",
"forwarding",
"id", "id",
"lastTrafficResetTime", "lastTrafficResetTime",
"listen", "listen",
@@ -2617,6 +2208,14 @@ export const SCHEMAS: Record<string, unknown> = {
"name": { "name": {
"type": "string" "type": "string"
}, },
"nodeType": {
"description": "NodeType distinguishes traditional panel-to-panel nodes from lightweight\nagent nodes. Agent nodes skip the panel reachability probe and the API\ntoken requirement; an agent token is issued on creation instead.",
"enum": [
"traditional",
"agent"
],
"type": "string"
},
"outboundTag": { "outboundTag": {
"type": "string" "type": "string"
}, },
@@ -2625,7 +2224,7 @@ export const SCHEMAS: Record<string, unknown> = {
}, },
"port": { "port": {
"maximum": 65535, "maximum": 65535,
"minimum": 1, "minimum": 0,
"type": "integer" "type": "integer"
}, },
"remark": { "remark": {
@@ -2778,6 +2377,11 @@ export const SCHEMAS: Record<string, unknown> = {
"format": "int64", "format": "int64",
"type": "integer" "type": "integer"
}, },
"nodeType": {
"description": "NodeType is \"agent\" for lightweight agent-managed nodes (an agent is\nbound to the node) and \"traditional\" otherwise. It is derived from the\nagent binding, not stored on the node row; read paths that serve the\npanel UI annotate it via annotateNodeTypes. Empty on views built\noutside those paths.",
"example": "traditional",
"type": "string"
},
"onlineCount": { "onlineCount": {
"example": 5, "example": 5,
"type": "integer" "type": "integer"
@@ -2945,68 +2549,6 @@ export const SCHEMAS: Record<string, unknown> = {
], ],
"type": "object" "type": "object"
}, },
"PeerActivity": {
"description": "PeerActivity is one peer's live embedded-Device-reported state, the\ncounterpart of an Xray access-log entry: a tunnel logs no requests, only\nhandshakes and bytes.",
"properties": {
"allowedIPs": {
"example": "10.8.1.2/32",
"type": "string"
},
"down": {
"example": 4194304,
"format": "int64",
"type": "integer"
},
"email": {
"example": "peer@example.com",
"type": "string"
},
"endpoint": {
"example": "203.0.113.9:51820",
"type": "string"
},
"handshake": {
"description": "Handshake is unix milliseconds, 0 when the peer has never connected.",
"example": 1735732800000,
"format": "int64",
"type": "integer"
},
"inboundId": {
"example": 1,
"type": "integer"
},
"interface": {
"example": "awg1",
"type": "string"
},
"online": {
"example": true,
"type": "boolean"
},
"tag": {
"example": "inbound-51820",
"type": "string"
},
"up": {
"example": 1048576,
"format": "int64",
"type": "integer"
}
},
"required": [
"allowedIPs",
"down",
"email",
"endpoint",
"handshake",
"inboundId",
"interface",
"online",
"tag",
"up"
],
"type": "object"
},
"ProbeResultUI": { "ProbeResultUI": {
"properties": { "properties": {
"cpuPct": { "cpuPct": {
+4 -110
View File
@@ -4,6 +4,7 @@ export type OnlineAPISupport = number;
export type ProcessState = string; export type ProcessState = string;
export type Protocol = string; export type Protocol = string;
export type SubLinkProvider = unknown; export type SubLinkProvider = unknown;
export type rawBytes = number[];
export type staticEgressResolver = string; export type staticEgressResolver = string;
export type trafficLocalApplyAction = number; export type trafficLocalApplyAction = number;
export type transportBits = number; export type transportBits = number;
@@ -11,64 +12,24 @@ export type transportBits = number;
export interface AllSetting { export interface AllSetting {
datepicker: string; datepicker: string;
expireDiff: number; expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
ipLimitAllowlist: 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; outboundDownThreshold: number;
pageSize: number; pageSize: number;
panelOutbound: string; panelOutbound: string;
remarkTemplate: string; remarkTemplate: string;
restartXrayOnClientDisable: boolean; restartXrayOnClientDisable: boolean;
sessionMaxAge: number; sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string; subAnnounce: string;
subCertFile: string; subCertFile: string;
subClashAutoDetect: boolean; subClashAutoDetect: boolean;
subClashEnable: boolean; subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string; subClashPath: string;
subClashRules: string;
subClashURI: string; subClashURI: string;
subClashUserAgentRegex: string; subClashUserAgentRegex: string;
subDomain: string; subDomain: string;
subEnable: boolean; subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean; subEncrypt: boolean;
subHideSettings: boolean; subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonAlwaysArray: boolean; subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean; subJsonAutoDetect: boolean;
subJsonEnable: boolean; subJsonEnable: boolean;
@@ -76,7 +37,6 @@ export interface AllSetting {
subJsonMux: string; subJsonMux: string;
subJsonObservatory: string; subJsonObservatory: string;
subJsonPath: string; subJsonPath: string;
subJsonRules: string;
subJsonURI: string; subJsonURI: string;
subJsonUserAgentRegex: string; subJsonUserAgentRegex: string;
subKeyFile: string; subKeyFile: string;
@@ -84,7 +44,6 @@ export interface AllSetting {
subPath: string; subPath: string;
subPort: number; subPort: number;
subProfileUrl: string; subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean; subShowIdentityOnAllLinks: boolean;
subSupportUrl: string; subSupportUrl: string;
subThemeDir: string; subThemeDir: string;
@@ -105,8 +64,6 @@ export interface AllSetting {
timeLocation: string; timeLocation: string;
trafficDiff: number; trafficDiff: number;
trustedProxyCIDRs: string; trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
warpUpdateInterval: number; warpUpdateInterval: number;
webBasePath: string; webBasePath: string;
webCertFile: string; webCertFile: string;
@@ -119,71 +76,28 @@ export interface AllSetting {
export interface AllSettingView { export interface AllSettingView {
datepicker: string; datepicker: string;
expireDiff: number; expireDiff: number;
externalTrafficInformEnable: boolean;
externalTrafficInformURI: string;
hasApiToken: boolean; hasApiToken: boolean;
hasLdapPassword: boolean;
hasNordSecret: boolean; hasNordSecret: boolean;
hasSmtpPassword: boolean;
hasTgBotToken: boolean; hasTgBotToken: boolean;
hasTwoFactorToken: boolean;
hasWarpSecret: boolean; hasWarpSecret: boolean;
ipLimitAllowlist: string; 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; outboundDownThreshold: number;
pageSize: number; pageSize: number;
panelOutbound: string; panelOutbound: string;
remarkTemplate: string; remarkTemplate: string;
restartXrayOnClientDisable: boolean; restartXrayOnClientDisable: boolean;
sessionMaxAge: number; sessionMaxAge: number;
smtpCpu: number;
smtpEnable: boolean;
smtpEnabledEvents: string;
smtpEncryptionType: string;
smtpFrom: string;
smtpFromName: string;
smtpHost: string;
smtpMemory: number;
smtpPassword: string;
smtpPort: number;
smtpTo: string;
smtpUsername: string;
subAnnounce: string; subAnnounce: string;
subCertFile: string; subCertFile: string;
subClashAutoDetect: boolean; subClashAutoDetect: boolean;
subClashEnable: boolean; subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string; subClashPath: string;
subClashRules: string;
subClashURI: string; subClashURI: string;
subClashUserAgentRegex: string; subClashUserAgentRegex: string;
subDomain: string; subDomain: string;
subEnable: boolean; subEnable: boolean;
subEnableRouting: boolean;
subEncrypt: boolean; subEncrypt: boolean;
subHideSettings: boolean; subHideSettings: boolean;
subIncyEnableRouting: boolean;
subIncyRoutingRules: string;
subJsonAlwaysArray: boolean; subJsonAlwaysArray: boolean;
subJsonAutoDetect: boolean; subJsonAutoDetect: boolean;
subJsonEnable: boolean; subJsonEnable: boolean;
@@ -191,7 +105,6 @@ export interface AllSettingView {
subJsonMux: string; subJsonMux: string;
subJsonObservatory: string; subJsonObservatory: string;
subJsonPath: string; subJsonPath: string;
subJsonRules: string;
subJsonURI: string; subJsonURI: string;
subJsonUserAgentRegex: string; subJsonUserAgentRegex: string;
subKeyFile: string; subKeyFile: string;
@@ -199,7 +112,6 @@ export interface AllSettingView {
subPath: string; subPath: string;
subPort: number; subPort: number;
subProfileUrl: string; subProfileUrl: string;
subRoutingRules: string;
subShowIdentityOnAllLinks: boolean; subShowIdentityOnAllLinks: boolean;
subSupportUrl: string; subSupportUrl: string;
subThemeDir: string; subThemeDir: string;
@@ -220,8 +132,6 @@ export interface AllSettingView {
timeLocation: string; timeLocation: string;
trafficDiff: number; trafficDiff: number;
trustedProxyCIDRs: string; trustedProxyCIDRs: string;
twoFactorEnable: boolean;
twoFactorToken: string;
warpUpdateInterval: number; warpUpdateInterval: number;
webBasePath: string; webBasePath: string;
webCertFile: string; webCertFile: string;
@@ -231,12 +141,6 @@ export interface AllSettingView {
webPort: number; webPort: number;
} }
export interface AmneziaWGLogs {
events: string[];
peers: PeerActivity[];
running: boolean;
}
export interface ApiToken { export interface ApiToken {
createdAt: number; createdAt: number;
enabled: boolean; enabled: boolean;
@@ -481,6 +385,7 @@ export interface Inbound {
enable: boolean; enable: boolean;
expiryTime: number; expiryTime: number;
fallbackParent?: FallbackParentInfo | null; fallbackParent?: FallbackParentInfo | null;
forwarding: string;
id: number; id: number;
lastTrafficResetTime: number; lastTrafficResetTime: number;
listen: string; listen: string;
@@ -601,6 +506,7 @@ export interface NodeMutationRequest {
inboundSyncMode: string; inboundSyncMode: string;
inboundTags: string[]; inboundTags: string[];
name: string; name: string;
nodeType?: string;
outboundTag: string; outboundTag: string;
pinnedCertSha256: string; pinnedCertSha256: string;
port: number; port: number;
@@ -635,6 +541,7 @@ export interface NodeView {
name: string; name: string;
netDown: number; netDown: number;
netUp: number; netUp: number;
nodeType?: string;
onlineCount: number; onlineCount: number;
outboundTag: string; outboundTag: string;
panelVersion: string; panelVersion: string;
@@ -668,19 +575,6 @@ export interface PanelUpdateStatus {
state: string; state: string;
} }
export interface PeerActivity {
allowedIPs: string;
down: number;
email: string;
endpoint: string;
handshake: number;
inboundId: number;
interface: string;
online: boolean;
tag: string;
up: number;
}
export interface ProbeResultUI { export interface ProbeResultUI {
cpuPct: number; cpuPct: number;
error: string; error: string;
+9 -115
View File
@@ -15,6 +15,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
export const SubLinkProviderSchema = z.unknown(); export const SubLinkProviderSchema = z.unknown();
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>; export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
export const rawBytesSchema = z.array(z.number().int());
export type rawBytes = z.infer<typeof rawBytesSchema>;
export const staticEgressResolverSchema = z.string(); export const staticEgressResolverSchema = z.string();
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>; export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
@@ -27,64 +30,24 @@ export type transportBits = z.infer<typeof transportBitsSchema>;
export const AllSettingSchema = z.object({ export const AllSettingSchema = z.object({
datepicker: z.string(), datepicker: z.string(),
expireDiff: z.number().int().min(0), expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
ipLimitAllowlist: z.string(), 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), outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000), pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(), panelOutbound: z.string(),
remarkTemplate: z.string(), remarkTemplate: z.string(),
restartXrayOnClientDisable: z.boolean(), restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600), sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(), subAnnounce: z.string(),
subCertFile: z.string(), subCertFile: z.string(),
subClashAutoDetect: z.boolean(), subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(), subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(), subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(), subClashURI: z.string(),
subClashUserAgentRegex: z.string(), subClashUserAgentRegex: z.string(),
subDomain: z.string(), subDomain: z.string(),
subEnable: z.boolean(), subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(), subEncrypt: z.boolean(),
subHideSettings: z.boolean(), subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(), subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(), subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
@@ -92,15 +55,13 @@ export const AllSettingSchema = z.object({
subJsonMux: z.string(), subJsonMux: z.string(),
subJsonObservatory: z.string(), subJsonObservatory: z.string(),
subJsonPath: z.string(), subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(), subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(), subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(), subKeyFile: z.string(),
subListen: z.string(), subListen: z.string(),
subPath: z.string(), subPath: z.string(),
subPort: z.number().int().min(1).max(65535), subPort: z.number().int().min(0).max(65535),
subProfileUrl: z.string(), subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(), subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(), subSupportUrl: z.string(),
subThemeDir: z.string(), subThemeDir: z.string(),
@@ -121,8 +82,6 @@ export const AllSettingSchema = z.object({
timeLocation: z.string(), timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100), trafficDiff: z.number().int().min(0).max(100),
trustedProxyCIDRs: z.string(), trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0), warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(), webBasePath: z.string(),
webCertFile: z.string(), webCertFile: z.string(),
@@ -136,71 +95,28 @@ export type AllSetting = z.infer<typeof AllSettingSchema>;
export const AllSettingViewSchema = z.object({ export const AllSettingViewSchema = z.object({
datepicker: z.string(), datepicker: z.string(),
expireDiff: z.number().int().min(0), expireDiff: z.number().int().min(0),
externalTrafficInformEnable: z.boolean(),
externalTrafficInformURI: z.string(),
hasApiToken: z.boolean(), hasApiToken: z.boolean(),
hasLdapPassword: z.boolean(),
hasNordSecret: z.boolean(), hasNordSecret: z.boolean(),
hasSmtpPassword: z.boolean(),
hasTgBotToken: z.boolean(), hasTgBotToken: z.boolean(),
hasTwoFactorToken: z.boolean(),
hasWarpSecret: z.boolean(), hasWarpSecret: z.boolean(),
ipLimitAllowlist: z.string(), 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), outboundDownThreshold: z.number().int().min(1).max(100),
pageSize: z.number().int().min(0).max(1000), pageSize: z.number().int().min(0).max(1000),
panelOutbound: z.string(), panelOutbound: z.string(),
remarkTemplate: z.string(), remarkTemplate: z.string(),
restartXrayOnClientDisable: z.boolean(), restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600), sessionMaxAge: z.number().int().min(1).max(525600),
smtpCpu: z.number().int().min(0).max(100),
smtpEnable: z.boolean(),
smtpEnabledEvents: z.string(),
smtpEncryptionType: z.string(),
smtpFrom: z.string(),
smtpFromName: z.string(),
smtpHost: z.string(),
smtpMemory: z.number().int().min(0).max(100),
smtpPassword: z.string(),
smtpPort: z.number().int().min(1).max(65535),
smtpTo: z.string(),
smtpUsername: z.string(),
subAnnounce: z.string(), subAnnounce: z.string(),
subCertFile: z.string(), subCertFile: z.string(),
subClashAutoDetect: z.boolean(), subClashAutoDetect: z.boolean(),
subClashEnable: z.boolean(), subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(), subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(), subClashURI: z.string(),
subClashUserAgentRegex: z.string(), subClashUserAgentRegex: z.string(),
subDomain: z.string(), subDomain: z.string(),
subEnable: z.boolean(), subEnable: z.boolean(),
subEnableRouting: z.boolean(),
subEncrypt: z.boolean(), subEncrypt: z.boolean(),
subHideSettings: z.boolean(), subHideSettings: z.boolean(),
subIncyEnableRouting: z.boolean(),
subIncyRoutingRules: z.string(),
subJsonAlwaysArray: z.boolean(), subJsonAlwaysArray: z.boolean(),
subJsonAutoDetect: z.boolean(), subJsonAutoDetect: z.boolean(),
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
@@ -208,15 +124,13 @@ export const AllSettingViewSchema = z.object({
subJsonMux: z.string(), subJsonMux: z.string(),
subJsonObservatory: z.string(), subJsonObservatory: z.string(),
subJsonPath: z.string(), subJsonPath: z.string(),
subJsonRules: z.string(),
subJsonURI: z.string(), subJsonURI: z.string(),
subJsonUserAgentRegex: z.string(), subJsonUserAgentRegex: z.string(),
subKeyFile: z.string(), subKeyFile: z.string(),
subListen: z.string(), subListen: z.string(),
subPath: z.string(), subPath: z.string(),
subPort: z.number().int().min(1).max(65535), subPort: z.number().int().min(0).max(65535),
subProfileUrl: z.string(), subProfileUrl: z.string(),
subRoutingRules: z.string(),
subShowIdentityOnAllLinks: z.boolean(), subShowIdentityOnAllLinks: z.boolean(),
subSupportUrl: z.string(), subSupportUrl: z.string(),
subThemeDir: z.string(), subThemeDir: z.string(),
@@ -237,8 +151,6 @@ export const AllSettingViewSchema = z.object({
timeLocation: z.string(), timeLocation: z.string(),
trafficDiff: z.number().int().min(0).max(100), trafficDiff: z.number().int().min(0).max(100),
trustedProxyCIDRs: z.string(), trustedProxyCIDRs: z.string(),
twoFactorEnable: z.boolean(),
twoFactorToken: z.string(),
warpUpdateInterval: z.number().int().min(0), warpUpdateInterval: z.number().int().min(0),
webBasePath: z.string(), webBasePath: z.string(),
webCertFile: z.string(), webCertFile: z.string(),
@@ -249,13 +161,6 @@ export const AllSettingViewSchema = z.object({
}); });
export type AllSettingView = z.infer<typeof AllSettingViewSchema>; export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
export const AmneziaWGLogsSchema = z.object({
events: z.array(z.string()),
peers: z.array(z.lazy(() => PeerActivitySchema)),
running: z.boolean(),
});
export type AmneziaWGLogs = z.infer<typeof AmneziaWGLogsSchema>;
export const ApiTokenSchema = z.object({ export const ApiTokenSchema = z.object({
createdAt: z.number().int(), createdAt: z.number().int(),
enabled: z.boolean(), enabled: z.boolean(),
@@ -517,6 +422,7 @@ export const InboundSchema = z.object({
enable: z.boolean(), enable: z.boolean(),
expiryTime: z.number().int(), expiryTime: z.number().int(),
fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(), fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
forwarding: z.string(),
id: z.number().int(), id: z.number().int(),
lastTrafficResetTime: z.number().int(), lastTrafficResetTime: z.number().int(),
listen: z.string(), listen: z.string(),
@@ -643,9 +549,10 @@ export const NodeMutationRequestSchema = z.object({
inboundSyncMode: z.enum(['all', 'selected']), inboundSyncMode: z.enum(['all', 'selected']),
inboundTags: z.array(z.string()), inboundTags: z.array(z.string()),
name: z.string(), name: z.string(),
nodeType: z.enum(['traditional', 'agent']).optional(),
outboundTag: z.string(), outboundTag: z.string(),
pinnedCertSha256: z.string(), pinnedCertSha256: z.string(),
port: z.number().int().min(1).max(65535), port: z.number().int().min(0).max(65535),
remark: z.string(), remark: z.string(),
scheme: z.enum(['http', 'https']), scheme: z.enum(['http', 'https']),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']), tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
@@ -678,6 +585,7 @@ export const NodeViewSchema = z.object({
name: z.string(), name: z.string(),
netDown: z.number().int(), netDown: z.number().int(),
netUp: z.number().int(), netUp: z.number().int(),
nodeType: z.string().optional(),
onlineCount: z.number().int(), onlineCount: z.number().int(),
outboundTag: z.string(), outboundTag: z.string(),
panelVersion: z.string(), panelVersion: z.string(),
@@ -714,20 +622,6 @@ export const PanelUpdateStatusSchema = z.object({
}); });
export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>; export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
export const PeerActivitySchema = z.object({
allowedIPs: z.string(),
down: z.number().int(),
email: z.string(),
endpoint: z.string(),
handshake: z.number().int(),
inboundId: z.number().int(),
interface: z.string(),
online: z.boolean(),
tag: z.string(),
up: z.number().int(),
});
export type PeerActivity = z.infer<typeof PeerActivitySchema>;
export const ProbeResultUISchema = z.object({ export const ProbeResultUISchema = z.object({
cpuPct: z.number(), cpuPct: z.number(),
error: z.string(), error: z.string(),
+1 -33
View File
@@ -162,7 +162,7 @@ async function fetchDefaults(): Promise<Record<string, unknown>> {
} }
export interface UseClientsOptions { export interface UseClientsOptions {
// Callers that only need the mutations — the bulk modals, the groups page — // Callers that only need the mutations — the bulk modals —
// pass false. Mounting them used to start a second 5-second poll of the paged // pass false. Mounting them used to start a second 5-second poll of the paged
// list whose result they never read, which on a large panel means a full // list whose result they never read, which on a large panel means a full
// summary aggregate every 5 seconds for nothing. // summary aggregate every 5 seconds for nothing.
@@ -328,22 +328,6 @@ export function useClients(options: UseClientsOptions = {}) {
}, },
}); });
const bulkAddToGroupMut = useMutation({
mutationFn: (body: { emails: string[]; group: string }) =>
HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidateAll();
},
});
const bulkRemoveFromGroupMut = useMutation({
mutationFn: (body: { emails: string[] }) =>
HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidateAll();
},
});
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ email, client }: { email: string; client: unknown }) => mutationFn: ({ email, client }: { email: string; client: unknown }) =>
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS), HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
@@ -583,20 +567,6 @@ export function useClients(options: UseClientsOptions = {}) {
}, },
[bulkSetEnableMut], [bulkSetEnableMut],
); );
const bulkAddToGroup = useCallback(
(emails: string[], group: string) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkAddToGroupMut.mutateAsync({ emails, group });
},
[bulkAddToGroupMut],
);
const bulkRemoveFromGroup = useCallback(
(emails: string[]) => {
if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
return bulkRemoveFromGroupMut.mutateAsync({ emails });
},
[bulkRemoveFromGroupMut],
);
const attach = useCallback( const attach = useCallback(
(email: string, inboundIds: number[]) => { (email: string, inboundIds: number[]) => {
if (!email) return Promise.resolve(null as unknown as Msg<unknown>); if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
@@ -807,8 +777,6 @@ export function useClients(options: UseClientsOptions = {}) {
bulkAdjust, bulkAdjust,
bulkEnable, bulkEnable,
bulkDisable, bulkDisable,
bulkAddToGroup,
bulkRemoveFromGroup,
attach, attach,
setExternalLinks, setExternalLinks,
bulkAttach, bulkAttach,
+1 -3
View File
@@ -6,14 +6,12 @@ const TITLE_KEYS: Record<string, string> = {
'/': 'menu.dashboard', '/': 'menu.dashboard',
'/inbounds': 'menu.inbounds', '/inbounds': 'menu.inbounds',
'/clients': 'menu.clients', '/clients': 'menu.clients',
'/groups': 'menu.groups',
'/nodes': 'menu.nodes', '/nodes': 'menu.nodes',
'/hosts': 'menu.hosts', '/hosts': 'menu.hosts',
'/settings': 'menu.settings', '/settings': 'menu.settings',
'/xray': 'menu.xray', '/xray': 'menu.xray',
'/outbound': 'menu.outbounds', '/outbound': 'menu.outbounds',
'/routing': 'menu.routing', '/routing': 'menu.routing',
'/api-docs': 'menu.apiDocs',
}; };
export function usePageTitle() { export function usePageTitle() {
@@ -22,7 +20,7 @@ export function usePageTitle() {
useEffect(() => { useEffect(() => {
const key = TITLE_KEYS[pathname]; const key = TITLE_KEYS[pathname];
const title = key ? t(key) : '3X-UI'; const title = key ? t(key) : 'L-UI';
const host = window.location.hostname; const host = window.location.hostname;
document.title = host ? `${host} - ${title}` : title; document.title = host ? `${host} - ${title}` : title;
}, [pathname, t]); }, [pathname, t]);
+5 -281
View File
@@ -7,46 +7,15 @@ import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys'; import { keys } from '@/api/queryKeys';
import { import {
OutboundTrafficListSchema, OutboundTrafficListSchema,
OutboundTestResultListSchema,
XrayConfigPayloadSchema, XrayConfigPayloadSchema,
XraySettingsValueSchema, XraySettingsValueSchema,
type OutboundTestResult,
type OutboundTrafficRow, type OutboundTrafficRow,
} from '@/schemas/xray'; } from '@/schemas/xray';
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204'; export type { OutboundTrafficRow };
// 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 type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
export interface OutboundTestState {
testing?: boolean;
result?: OutboundTestResult | null;
mode?: string;
}
export type SetTemplate = ( export type SetTemplate = (
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null), next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
) => void; ) => void;
@@ -60,30 +29,14 @@ export interface UseXraySettingResult {
setXraySetting: (next: string) => void; setXraySetting: (next: string) => void;
templateSettings: XraySettingsValue | null; templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate; setTemplateSettings: SetTemplate;
outboundTestUrl: string;
setOutboundTestUrl: (v: string) => void;
inboundTags: string[]; inboundTags: string[];
clientReverseTags: string[]; clientReverseTags: string[];
subscriptionOutbounds: unknown[]; subscriptionOutbounds: unknown[];
subscriptionOutboundTags: string[]; subscriptionOutboundTags: string[];
outboundsTraffic: OutboundTrafficRow[]; outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
subscriptionTestStates: Record<string, OutboundTestState>;
testingAll: boolean;
fetchAll: () => Promise<void>; fetchAll: () => Promise<void>;
fetchOutboundsTraffic: () => Promise<void>; fetchOutboundsTraffic: () => Promise<void>;
resetOutboundsTraffic: (tag: string) => 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>; saveAll: () => Promise<void>;
resetToDefault: () => Promise<void>; resetToDefault: () => Promise<void>;
} }
@@ -136,9 +89,7 @@ export function useXraySetting(): UseXraySettingResult {
const [xraySetting, setXraySettingState] = useState(''); const [xraySetting, setXraySettingState] = useState('');
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null); const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
const [savedXraySetting, setSavedXraySetting] = useState(''); const [savedXraySetting, setSavedXraySetting] = useState('');
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
const config = configQuery.data; const config = configQuery.data;
const inboundTags = useMemo(() => config?.inboundTags || [], [config]); const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]); const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
@@ -147,21 +98,10 @@ export function useXraySetting(): UseXraySettingResult {
[config], [config],
); );
const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [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 syncingRef = useRef(false);
const xraySettingRef = useRef(''); const xraySettingRef = useRef('');
const outboundTestUrlRef = useRef(outboundTestUrl);
const savedXraySettingRef = useRef(savedXraySetting); const savedXraySettingRef = useRef(savedXraySetting);
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
const templateSettingsRef = useRef<XraySettingsValue | null>(null); const templateSettingsRef = useRef<XraySettingsValue | null>(null);
const subscriptionOutboundsRef = useRef<unknown[]>([]); const subscriptionOutboundsRef = useRef<unknown[]>([]);
@@ -169,9 +109,7 @@ export function useXraySetting(): UseXraySettingResult {
useEffect(() => { useEffect(() => {
xraySettingRef.current = xraySetting; xraySettingRef.current = xraySetting;
outboundTestUrlRef.current = outboundTestUrl;
savedXraySettingRef.current = savedXraySetting; savedXraySettingRef.current = savedXraySetting;
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
templateSettingsRef.current = templateSettings; templateSettingsRef.current = templateSettings;
subscriptionOutboundsRef.current = subscriptionOutbounds; subscriptionOutboundsRef.current = subscriptionOutbounds;
}); });
@@ -180,17 +118,12 @@ export function useXraySetting(): UseXraySettingResult {
// of the previous config after a refetch. Local edits win over the refetch. // of the previous config after a refetch. Local edits win over the refetch.
if (config && config !== syncedConfig) { if (config && config !== syncedConfig) {
setSyncedConfig(config); setSyncedConfig(config);
const isDirty = const isDirty = savedXraySetting !== xraySetting;
savedXraySetting !== xraySetting ||
savedOutboundTestUrl !== normalizeOutboundTestUrl(outboundTestUrl);
if (!isDirty) { if (!isDirty) {
const pretty = JSON.stringify(config.xraySetting, null, 2); const pretty = JSON.stringify(config.xraySetting, null, 2);
const nextUrl = normalizeOutboundTestUrl(config.outboundTestUrl || '');
setXraySettingState(pretty); setXraySettingState(pretty);
setTemplateSettingsState(config.xraySetting); setTemplateSettingsState(config.xraySetting);
setSavedXraySetting(pretty); setSavedXraySetting(pretty);
setOutboundTestUrlState(nextUrl);
setSavedOutboundTestUrl(nextUrl);
} }
} }
@@ -226,10 +159,6 @@ export function useXraySetting(): UseXraySettingResult {
}); });
}, []); }, []);
const setOutboundTestUrl = useCallback((v: string) => {
setOutboundTestUrlState(v);
}, []);
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: keys.xray.config() }); await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
}, [queryClient]); }, [queryClient]);
@@ -241,17 +170,14 @@ export function useXraySetting(): UseXraySettingResult {
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: async () => { mutationFn: async () => {
const sentXraySetting = xraySettingRef.current; const sentXraySetting = xraySettingRef.current;
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
const msg = await HttpUtil.post('/panel/api/xray/update', { const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: sentXraySetting, xraySetting: sentXraySetting,
outboundTestUrl: sentTestUrl,
}); });
return { msg, sentXraySetting, sentTestUrl }; return { msg, sentXraySetting };
}, },
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => { onSuccess: ({ msg, sentXraySetting }) => {
if (!msg?.success) return; if (!msg?.success) return;
setSavedXraySetting(sentXraySetting); setSavedXraySetting(sentXraySetting);
setSavedOutboundTestUrl(sentTestUrl);
queryClient.invalidateQueries({ queryKey: keys.xray.config() }); queryClient.invalidateQueries({ queryKey: keys.xray.config() });
}, },
}); });
@@ -291,193 +217,7 @@ export function useXraySetting(): UseXraySettingResult {
const spinning = saveMut.isPending || resetDefaultMut.isPending; const spinning = saveMut.isPending || resetDefaultMut.isPending;
// Shared POST + parse for a batch of outbound tests. The backend probes the const saveDisabled = savedXraySetting === xraySetting;
// 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]); const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
@@ -491,22 +231,14 @@ export function useXraySetting(): UseXraySettingResult {
setXraySetting, setXraySetting,
templateSettings, templateSettings,
setTemplateSettings, setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags, inboundTags,
clientReverseTags, clientReverseTags,
subscriptionOutbounds, subscriptionOutbounds,
subscriptionOutboundTags, subscriptionOutboundTags,
outboundsTraffic, outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll, fetchAll,
fetchOutboundsTraffic: fetchOutboundsTrafficCb, fetchOutboundsTraffic: fetchOutboundsTrafficCb,
resetOutboundsTraffic, resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll, saveAll,
resetToDefault, resetToDefault,
}), }),
@@ -519,22 +251,14 @@ export function useXraySetting(): UseXraySettingResult {
setXraySetting, setXraySetting,
templateSettings, templateSettings,
setTemplateSettings, setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags, inboundTags,
clientReverseTags, clientReverseTags,
subscriptionOutbounds, subscriptionOutbounds,
subscriptionOutboundTags, subscriptionOutboundTags,
outboundsTraffic, outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll, fetchAll,
fetchOutboundsTrafficCb, fetchOutboundsTrafficCb,
resetOutboundsTraffic, resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll, saveAll,
resetToDefault, resetToDefault,
], ],
+3 -13
View File
@@ -12,14 +12,12 @@ import {
ClusterOutlined, ClusterOutlined,
CodeOutlined, CodeOutlined,
DashboardOutlined, DashboardOutlined,
DatabaseOutlined,
ExportOutlined, ExportOutlined,
GithubOutlined, GithubOutlined,
GlobalOutlined, GlobalOutlined,
HeartOutlined, HeartOutlined,
ImportOutlined, ImportOutlined,
LogoutOutlined, LogoutOutlined,
MailOutlined,
MenuOutlined, MenuOutlined,
MessageOutlined, MessageOutlined,
MoonFilled, MoonFilled,
@@ -217,14 +215,12 @@ export default function AppSidebar() {
{ key: '/', icon: 'dashboard', title: t('menu.dashboard') }, { key: '/', icon: 'dashboard', title: t('menu.dashboard') },
{ key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') }, { key: '/inbounds', icon: 'inbound', title: t('menu.inbounds') },
{ key: '/clients', icon: 'team', title: t('menu.clients') }, { key: '/clients', icon: 'team', title: t('menu.clients') },
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') }, { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
{ key: '/hosts', icon: 'hosts', title: t('menu.hosts') }, { key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
{ key: '/outbound', icon: 'outbound', title: t('menu.outbounds') }, { key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
{ key: '/routing', icon: 'routing', title: t('menu.routing') }, { key: '/routing', icon: 'routing', title: t('menu.routing') },
{ key: '/settings', icon: 'setting', title: t('menu.settings') }, { key: '/settings', icon: 'setting', title: t('menu.settings') },
{ key: '/xray', icon: 'tool', title: t('menu.xray') }, { key: '/xray', icon: 'tool', title: t('menu.xray') },
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') }, { key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
], ],
[t], [t],
@@ -250,7 +246,6 @@ export default function AppSidebar() {
icon: <MessageOutlined />, icon: <MessageOutlined />,
label: t('pages.settings.TGBotSettings'), label: t('pages.settings.TGBotSettings'),
}, },
{ key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
{ {
key: '/settings#subscription', key: '/settings#subscription',
icon: <CloudServerOutlined />, icon: <CloudServerOutlined />,
@@ -275,12 +270,7 @@ export default function AppSidebar() {
}, [t, showSubFormats, showSubBalancers]); }, [t, showSubFormats, showSubBalancers]);
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>( const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(
() => [ () => [{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') }],
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
],
[t], [t],
); );
@@ -366,7 +356,7 @@ export default function AppSidebar() {
> >
<div className="sider-brand"> <div className="sider-brand">
<div className="brand-block"> <div className="brand-block">
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span> <span className="brand-text">{railCollapsed ? 'L' : 'L-UI'}</span>
</div> </div>
{!railCollapsed && ( {!railCollapsed && (
<div className="brand-actions"> <div className="brand-actions">
@@ -430,7 +420,7 @@ export default function AppSidebar() {
> >
<div className="drawer-header"> <div className="drawer-header">
<div className="brand-block"> <div className="brand-block">
<span className="drawer-brand">3X-UI</span> <span className="drawer-brand">L-UI</span>
</div> </div>
<div className="drawer-header-actions"> <div className="drawer-header-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} /> <DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
+3
View File
@@ -22,6 +22,9 @@ function parseVersionParts(version: string): [number, number, number] | null {
export function formatPanelVersion(version: string | undefined | null): string { export function formatPanelVersion(version: string | undefined | null): string {
const v = (version || '').trim(); const v = (version || '').trim();
if (!v) return ''; if (!v) return '';
// This fork's display versions (master "V1.0.0", agent "A1.0.0") are
// shown exactly as written — never re-prefixed or re-cased.
if (/^[VA]\d+\.\d+\.\d+$/.test(v)) return v;
const normalized = v.replace(/^v/i, ''); const normalized = v.replace(/^v/i, '');
return /^\d/.test(normalized) ? `v${normalized}` : v; return /^\d/.test(normalized) ? `v${normalized}` : v;
} }
+25 -6
View File
@@ -1,7 +1,9 @@
import type { import {
InboundFormValues, ForwardingSpecSchema,
ShareAddrStrategy, type ForwardingSpec,
TrafficReset, type InboundFormValues,
type ShareAddrStrategy,
type TrafficReset,
} from '@/schemas/forms/inbound-form'; } from '@/schemas/forms/inbound-form';
import type { InboundSettings } from '@/schemas/protocols/inbound'; import type { InboundSettings } from '@/schemas/protocols/inbound';
import { import {
@@ -49,6 +51,7 @@ export interface RawInboundRow {
trafficResetDay?: number; trafficResetDay?: number;
lastTrafficResetTime?: number; lastTrafficResetTime?: number;
nodeId?: number | null; nodeId?: number | null;
forwarding?: unknown;
shareAddrStrategy?: string; shareAddrStrategy?: string;
shareAddr?: string; shareAddr?: string;
subSortIndex?: number; subSortIndex?: number;
@@ -77,7 +80,8 @@ export interface WireInboundPayload {
sniffing: string; sniffing: string;
tag: string; tag: string;
clientStats?: unknown; clientStats?: unknown;
nodeId?: number; nodeId?: number | null;
forwarding: string;
shareAddrStrategy: ShareAddrStrategy; shareAddrStrategy: ShareAddrStrategy;
shareAddr: string; shareAddr: string;
subSortIndex: number; subSortIndex: number;
@@ -111,6 +115,11 @@ function coerceTrafficReset(v: unknown): TrafficReset {
: 'never'; : 'never';
} }
function coerceForwarding(v: unknown): ForwardingSpec {
const parsed = ForwardingSpecSchema.safeParse(coerceJsonObject(v));
return parsed.success ? parsed.data : ForwardingSpecSchema.parse({});
}
function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy { function coerceShareAddrStrategy(v: unknown): ShareAddrStrategy {
return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v) return typeof v === 'string' && (SHARE_ADDR_STRATEGIES as string[]).includes(v)
? (v as ShareAddrStrategy) ? (v as ShareAddrStrategy)
@@ -213,6 +222,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)), trafficResetDay: Math.min(31, Math.max(1, row.trafficResetDay ?? 1)),
lastTrafficResetTime: row.lastTrafficResetTime ?? 0, lastTrafficResetTime: row.lastTrafficResetTime ?? 0,
nodeId: row.nodeId ?? null, nodeId: row.nodeId ?? null,
forwarding: coerceForwarding(row.forwarding),
shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy), shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
shareAddr: row.shareAddr ?? '', shareAddr: row.shareAddr ?? '',
subSortIndex: Math.max(1, row.subSortIndex ?? 1), subSortIndex: Math.max(1, row.subSortIndex ?? 1),
@@ -383,7 +393,16 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
shareAddr: values.shareAddr, shareAddr: values.shareAddr,
subSortIndex: values.subSortIndex, subSortIndex: values.subSortIndex,
disableFlow: values.disableFlow, disableFlow: values.disableFlow,
// Forwarding rides as a JSON string like settings; direct mode stores
// an empty string so the row carries no forwarding residue.
forwarding:
values.forwarding && values.forwarding.mode === 'forward'
? JSON.stringify(values.forwarding)
: '',
}; };
if (values.nodeId != null) payload.nodeId = values.nodeId; // nodeId is always emitted (null = local panel): the update endpoint
// distinguishes an explicit null — "move the inbound back to local" —
// from an omitted key, which keeps the stored assignment untouched.
payload.nodeId = values.nodeId ?? null;
return payload; return payload;
} }
+3
View File
@@ -39,6 +39,7 @@ export type DBInboundInit = Partial<{
streamSettings: RawJsonField; streamSettings: RawJsonField;
tag: string; tag: string;
sniffing: RawJsonField; sniffing: RawJsonField;
forwarding: string;
clientStats: ClientStats[]; clientStats: ClientStats[];
nodeId: number | null; nodeId: number | null;
shareAddrStrategy: string; shareAddrStrategy: string;
@@ -88,6 +89,7 @@ export class DBInbound {
streamSettings: RawJsonField; streamSettings: RawJsonField;
tag: string; tag: string;
sniffing: RawJsonField; sniffing: RawJsonField;
forwarding: string;
clientStats: ClientStats[]; clientStats: ClientStats[];
nodeId: number | null; nodeId: number | null;
shareAddrStrategy: string; shareAddrStrategy: string;
@@ -119,6 +121,7 @@ export class DBInbound {
this.streamSettings = ''; this.streamSettings = '';
this.tag = ''; this.tag = '';
this.sniffing = ''; this.sniffing = '';
this.forwarding = '';
this.clientStats = []; this.clientStats = [];
this.nodeId = null; this.nodeId = null;
this.shareAddrStrategy = 'node'; this.shareAddrStrategy = 'node';
-421
View File
@@ -1,421 +0,0 @@
.api-docs-page.is-dark {
--sw-bg: #1f2026;
--sw-bg-soft: #25272e;
--sw-bg-input: #15161a;
--sw-bg-code: #0d0e12;
--sw-border: rgba(255, 255, 255, 0.08);
--sw-border-strong: rgba(255, 255, 255, 0.15);
--sw-text: rgba(255, 255, 255, 0.88);
--sw-text-muted: rgba(255, 255, 255, 0.6);
--sw-text-dim: rgba(255, 255, 255, 0.45);
--sw-accent: #58a6ff;
color-scheme: dark;
}
.api-docs-page.is-dark.is-ultra {
--sw-bg: #0a0a0d;
--sw-bg-soft: #131316;
--sw-bg-input: #050507;
--sw-bg-code: #000;
--sw-border: rgba(255, 255, 255, 0.06);
--sw-border-strong: rgba(255, 255, 255, 0.12);
}
.api-docs-page .content-shell {
background: var(--bg-page);
}
.api-docs-page .content-area {
padding: 16px;
max-width: 100%;
}
@media (max-width: 768px) {
.api-docs-page .content-area {
padding: 8px;
padding-top: 56px;
}
}
.api-docs-page .docs-wrapper {
background: var(--bg-card);
border-radius: 8px;
border: 1px solid var(--ant-color-border-secondary);
overflow: hidden;
}
.api-docs-page .swagger-ui {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* ──────────────────────────────────────────────────────────────────
Dark mode — Swagger UI does not ship a dark theme, so every visual
surface needs an explicit override. Method-color chips (GET / POST /
…) are left untouched because they carry meaning at a glance.
────────────────────────────────────────────────────────────────── */
.api-docs-page.is-dark .swagger-ui,
.api-docs-page.is-dark .swagger-ui .info .title,
.api-docs-page.is-dark .swagger-ui .info .title small pre,
.api-docs-page.is-dark .swagger-ui .info p,
.api-docs-page.is-dark .swagger-ui .info li,
.api-docs-page.is-dark .swagger-ui .info table,
.api-docs-page.is-dark .swagger-ui .opblock-tag,
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-path__deprecated,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary-description,
.api-docs-page.is-dark .swagger-ui .opblock-description-wrapper p,
.api-docs-page.is-dark .swagger-ui .opblock-external-docs-wrapper p,
.api-docs-page.is-dark .swagger-ui .opblock-title_normal p,
.api-docs-page.is-dark .swagger-ui table thead tr td,
.api-docs-page.is-dark .swagger-ui table thead tr th,
.api-docs-page.is-dark .swagger-ui table tbody tr td,
.api-docs-page.is-dark .swagger-ui .parameter__name,
.api-docs-page.is-dark .swagger-ui .parameter__type,
.api-docs-page.is-dark .swagger-ui .parameter__in,
.api-docs-page.is-dark .swagger-ui .parameter__extension,
.api-docs-page.is-dark .swagger-ui .response-col_status,
.api-docs-page.is-dark .swagger-ui .response-col_description,
.api-docs-page.is-dark .swagger-ui .response-col_links,
.api-docs-page.is-dark .swagger-ui .responses-inner h4,
.api-docs-page.is-dark .swagger-ui .responses-inner h5,
.api-docs-page.is-dark .swagger-ui label,
.api-docs-page.is-dark .swagger-ui .tab li,
.api-docs-page.is-dark .swagger-ui .tab li button,
.api-docs-page.is-dark .swagger-ui .markdown,
.api-docs-page.is-dark .swagger-ui .markdown p,
.api-docs-page.is-dark .swagger-ui .markdown li,
.api-docs-page.is-dark .swagger-ui .renderedmarkdown p,
.api-docs-page.is-dark .swagger-ui .renderedmarkdown li,
.api-docs-page.is-dark .swagger-ui .model-title,
.api-docs-page.is-dark .swagger-ui .model,
.api-docs-page.is-dark .swagger-ui .model-toggle:after,
.api-docs-page.is-dark .swagger-ui section.models h4,
.api-docs-page.is-dark .swagger-ui section.models h5,
.api-docs-page.is-dark .swagger-ui .auth-container h4,
.api-docs-page.is-dark .swagger-ui .auth-container h6,
.api-docs-page.is-dark .swagger-ui .scopes h2,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header h3,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content h4,
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content p,
.api-docs-page.is-dark .swagger-ui .servers-title {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .opblock-tag small,
.api-docs-page.is-dark .swagger-ui .opblock-summary-description,
.api-docs-page.is-dark .swagger-ui .parameter__in,
.api-docs-page.is-dark .swagger-ui .parameter__type,
.api-docs-page.is-dark .swagger-ui .parameter__extension,
.api-docs-page.is-dark .swagger-ui .opblock-title_normal small,
.api-docs-page.is-dark .swagger-ui .servers > label,
.api-docs-page.is-dark .swagger-ui .servers > label span,
.api-docs-page.is-dark .swagger-ui .response-control-media-type__accept-message {
color: var(--sw-text-muted);
}
.api-docs-page.is-dark .swagger-ui .opblock-tag {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .opblock {
background: var(--sw-bg-soft);
border-color: var(--sw-border);
box-shadow: none;
}
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header {
background: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 -1px 0 var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header h4,
.api-docs-page.is-dark .swagger-ui .opblock .opblock-section-header label {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .opblock .opblock-summary {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .opblock-body pre.microlight,
.api-docs-page.is-dark .swagger-ui .highlight-code,
.api-docs-page.is-dark .swagger-ui .microlight,
.api-docs-page.is-dark .swagger-ui pre.example,
.api-docs-page.is-dark .swagger-ui code {
background: var(--sw-bg-code);
color: rgba(255, 255, 255, 0.92);
}
.api-docs-page.is-dark .swagger-ui .highlight-code .copy-to-clipboard {
background: rgba(255, 255, 255, 0.06);
}
.api-docs-page.is-dark .swagger-ui input[type='text'],
.api-docs-page.is-dark .swagger-ui input[type='password'],
.api-docs-page.is-dark .swagger-ui input[type='search'],
.api-docs-page.is-dark .swagger-ui input[type='email'],
.api-docs-page.is-dark .swagger-ui input[type='file'],
.api-docs-page.is-dark .swagger-ui textarea {
background: var(--sw-bg-input);
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui select {
background-color: var(--sw-bg-input);
background-image: url("data:image/svg+xml;charset=utf-8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='%23b7bcbf' d='M13.418 7.859a.695.695 0 0 1 .978 0 .68.68 0 0 1 0 .969l-3.908 3.83a.697.697 0 0 1-.979 0l-3.908-3.83a.68.68 0 0 1 0-.969.695.695 0 0 1 .978 0L10 11z'/></svg>");
background-position: right 10px center;
background-repeat: no-repeat;
background-size: 20px;
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui select option {
background-color: var(--sw-bg-input);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.api-docs-page.is-dark .swagger-ui input::placeholder,
.api-docs-page.is-dark .swagger-ui textarea::placeholder {
color: var(--sw-text-dim);
}
.api-docs-page.is-dark .swagger-ui .scheme-container {
background: var(--sw-bg-soft);
box-shadow: inset 0 -1px 0 var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize {
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .auth-wrapper .authorize svg {
fill: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .model-box,
.api-docs-page.is-dark .swagger-ui section.models {
background: var(--sw-bg-soft);
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models.is-open h4 {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models .model-container {
background: rgba(255, 255, 255, 0.02);
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui section.models .model-container:hover {
background: rgba(255, 255, 255, 0.04);
}
.api-docs-page.is-dark .swagger-ui .prop-type,
.api-docs-page.is-dark .swagger-ui .prop-format {
color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui .property.primitive {
color: var(--sw-text-muted);
}
.api-docs-page.is-dark .swagger-ui .opblock-title_normal h4 {
color: var(--sw-text);
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui table.parameters,
.api-docs-page.is-dark .swagger-ui table.responses-table,
.api-docs-page.is-dark .swagger-ui table thead tr {
border-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui table tbody tr td {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui table thead tr {
border-bottom-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .response-col_status {
font-weight: 600;
}
.api-docs-page.is-dark .swagger-ui .btn {
background: rgba(255, 255, 255, 0.06);
color: var(--sw-text);
border-color: var(--sw-border-strong);
box-shadow: none;
}
.api-docs-page.is-dark .swagger-ui .btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.api-docs-page.is-dark .swagger-ui .btn.execute {
background: var(--sw-accent);
color: #0d1117;
border-color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui .btn.execute:hover {
background: #79b9ff;
}
.api-docs-page.is-dark .swagger-ui .btn.authorize {
color: #52c41a;
border-color: rgba(82, 196, 26, 0.4);
background: rgba(82, 196, 26, 0.08);
}
.api-docs-page.is-dark .swagger-ui .btn.authorize svg {
fill: #52c41a;
}
.api-docs-page.is-dark .swagger-ui .authorization__btn svg,
.api-docs-page.is-dark .swagger-ui .expand-operation svg,
.api-docs-page.is-dark .swagger-ui .opblock-control-arrow svg {
fill: var(--sw-text);
opacity: 1;
}
.api-docs-page.is-dark .swagger-ui .authorization__btn .locked,
.api-docs-page.is-dark .swagger-ui .authorization__btn .unlocked {
opacity: 1;
}
.api-docs-page.is-dark .swagger-ui .btn.cancel {
color: #ff7875;
border-color: rgba(255, 120, 117, 0.4);
background: rgba(255, 120, 117, 0.08);
}
.api-docs-page.is-dark .swagger-ui .btn.btn-clear,
.api-docs-page.is-dark .swagger-ui .btn-clear,
.api-docs-page.is-dark .swagger-ui .try-out__btn {
color: var(--sw-text);
background: rgba(255, 255, 255, 0.06);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .btn.btn-clear:hover,
.api-docs-page.is-dark .swagger-ui .btn-clear:hover,
.api-docs-page.is-dark .swagger-ui .try-out__btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.api-docs-page.is-dark .swagger-ui .filter .operation-filter-input {
background: var(--sw-bg-input);
border-color: var(--sw-border-strong);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux {
background: var(--sw-bg);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-header {
border-bottom-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .dialog-ux .modal-ux-content {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .arrow,
.api-docs-page.is-dark .swagger-ui svg.arrow {
fill: var(--sw-text-muted);
}
.api-docs-page.is-dark .swagger-ui .opblock-summary-control:focus {
outline-color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui a,
.api-docs-page.is-dark .swagger-ui .info a,
.api-docs-page.is-dark .swagger-ui .info hgroup.main a,
.api-docs-page.is-dark .swagger-ui .info .base-url,
.api-docs-page.is-dark .swagger-ui .info__contact a,
.api-docs-page.is-dark .swagger-ui .info__license a,
.api-docs-page.is-dark .swagger-ui .info__tos a {
color: var(--sw-accent);
}
.api-docs-page.is-dark .swagger-ui a:hover {
color: #79b9ff;
}
.api-docs-page.is-dark .swagger-ui .info .title small {
background: rgba(255, 255, 255, 0.08);
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .info .title small pre {
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .response-control-media-type--accept-controller select {
border-color: rgba(82, 196, 26, 0.5);
}
.api-docs-page.is-dark .swagger-ui .loading-container .loading:before {
border-color: var(--sw-accent) transparent transparent;
}
.api-docs-page.is-dark .swagger-ui .json-schema-form-item input,
.api-docs-page.is-dark .swagger-ui .json-schema-form-item select {
background: var(--sw-bg-input);
color: var(--sw-text);
border-color: var(--sw-border-strong);
}
.api-docs-page.is-dark .swagger-ui .topbar {
background: var(--sw-bg);
}
.api-docs-page.is-dark .swagger-ui .information-container {
background: transparent;
}
.api-docs-page.is-dark .swagger-ui .opblock-summary-method {
text-shadow: none;
}
.api-docs-page.is-dark .swagger-ui .auth-btn-wrapper {
border-top-color: var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .servers .computed-url,
.api-docs-page.is-dark .swagger-ui .computed-url {
background: var(--sw-bg-code);
color: var(--sw-text);
border: 1px solid var(--sw-border);
}
.api-docs-page.is-dark .swagger-ui .computed-url code,
.api-docs-page.is-dark .swagger-ui .servers .computed-url code {
background: transparent;
color: var(--sw-text);
}
.api-docs-page.is-dark .swagger-ui .errors-wrapper {
background: rgba(255, 77, 79, 0.08);
border-color: rgba(255, 77, 79, 0.3);
}
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors h4,
.api-docs-page.is-dark .swagger-ui .errors-wrapper .errors small {
color: var(--sw-text);
}
@@ -1,46 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ConfigProvider, Layout } from 'antd';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import './ApiDocsPage.css';
const basePath = window.X_UI_BASE_PATH || '';
const openApiUrl = `${basePath}panel/api/openapi.json`;
export default function ApiDocsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { t } = useTranslation();
const pageClass = useMemo(() => {
const classes = ['api-docs-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
return (
<ConfigProvider theme={antdThemeConfig}>
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content className="content-area">
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
<SwaggerUI
url={openApiUrl}
docExpansion="list"
deepLinking={false}
tryItOutEnabled
persistAuthorization
/>
</div>
</Layout.Content>
</Layout>
</Layout>
</ConfigProvider>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,89 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AutoComplete, Form, Modal, message } from 'antd';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { FormField } from '@/components/form/rhf';
type GroupFormValues = { group: string };
const EMPTY: GroupFormValues = { group: '' };
interface BulkAddToGroupModalProps {
open: boolean;
count: number;
groups: string[];
onOpenChange: (open: boolean) => void;
onSubmit: (group: string) => Promise<{ affected?: number } | null>;
}
export default function BulkAddToGroupModal({
open,
count,
groups,
onOpenChange,
onSubmit,
}: BulkAddToGroupModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [submitting, setSubmitting] = useState(false);
const methods = useForm<GroupFormValues>({ defaultValues: EMPTY });
const group = useWatch({ control: methods.control, name: 'group' });
useEffect(() => {
if (open) methods.reset(EMPTY);
}, [open, methods]);
async function submit() {
const next = (methods.getValues().group ?? '').trim();
if (!next) return;
setSubmitting(true);
try {
const result = await onSubmit(next);
if (result) {
const affected = result.affected ?? 0;
messageApi.success(
t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }),
);
onOpenChange(false);
}
} finally {
setSubmitting(false);
}
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={t('pages.clients.addToGroupTitle', { count })}
okText={t('add')}
cancelText={t('cancel')}
confirmLoading={submitting}
okButtonProps={{ disabled: !(group ?? '').trim() }}
onCancel={() => onOpenChange(false)}
onOk={submit}
destroyOnHidden
>
<FormProvider {...methods}>
<Form layout="vertical">
<FormField
name="group"
label={t('pages.clients.group')}
tooltip={t('pages.clients.addToGroupTooltip')}
transform={{ output: (v) => v ?? '' }}
>
<AutoComplete
placeholder={t('pages.clients.groupName')}
options={groups.map((g) => ({ value: g }))}
allowClear
autoFocus
/>
</FormField>
</Form>
</FormProvider>
</Modal>
</>
);
}
@@ -37,6 +37,8 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'hysteria', 'hysteria',
'wireguard', 'wireguard',
'amneziawg', 'amneziawg',
'socks',
'mixed',
]); ]);
const EMPTY: ClientBulkAddFormValues = { const EMPTY: ClientBulkAddFormValues = {
@@ -61,6 +61,8 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
'wireguard', 'wireguard',
'mtproto', 'mtproto',
'amneziawg', 'amneziawg',
'socks',
'mixed',
]); ]);
const CLIENT_FORM_MODAL_Z_INDEX = 1000; const CLIENT_FORM_MODAL_Z_INDEX = 1000;
@@ -43,7 +43,6 @@ import {
SearchOutlined, SearchOutlined,
SortAscendingOutlined, SortAscendingOutlined,
StopOutlined, StopOutlined,
TagsOutlined,
TeamOutlined, TeamOutlined,
UploadOutlined, UploadOutlined,
UsergroupAddOutlined, UsergroupAddOutlined,
@@ -83,7 +82,6 @@ const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal')); const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
const FilterDrawer = lazy(() => import('./FilterDrawer')); const FilterDrawer = lazy(() => import('./FilterDrawer'));
const SubLinksModal = lazy(() => import('./SubLinksModal')); const SubLinksModal = lazy(() => import('./SubLinksModal'));
const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal')); const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal')); const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
const TextModal = lazy(() => import('@/components/feedback/TextModal')); const TextModal = lazy(() => import('@/components/feedback/TextModal'));
@@ -97,45 +95,6 @@ const FILTER_STATE_KEY = 'clientsFilterState';
const DISABLED_PAGE_SIZE = 200; const DISABLED_PAGE_SIZE = 200;
const DEFAULT_TABLE_PAGE_SIZE = 25; const DEFAULT_TABLE_PAGE_SIZE = 25;
function UngroupIcon() {
return (
<span
style={{
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '1em',
height: '1em',
}}
>
<TagsOutlined />
<span
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<span
style={{
display: 'block',
width: '125%',
height: '1.5px',
background: 'currentColor',
transform: 'rotate(-45deg)',
borderRadius: '1px',
}}
/>
</span>
</span>
);
}
// The server sends exact counters but caps the email arrays behind them, so a // The server sends exact counters but caps the email arrays behind them, so a
// panel with thousands of depleted clients neither ships nor renders them all. // panel with thousands of depleted clients neither ships nor renders them all.
// The trailing chip reports what the popover left out. // The trailing chip reports what the popover left out.
@@ -314,8 +273,6 @@ export default function ClientsPage() {
bulkAdjust, bulkAdjust,
bulkEnable, bulkEnable,
bulkDisable, bulkDisable,
bulkAddToGroup,
bulkRemoveFromGroup,
attach, attach,
setExternalLinks, setExternalLinks,
bulkAttach, bulkAttach,
@@ -363,7 +320,6 @@ export default function ClientsPage() {
const [bulkAddOpen, setBulkAddOpen] = useState(false); const [bulkAddOpen, setBulkAddOpen] = useState(false);
const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false); const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false);
const [subLinksOpen, setSubLinksOpen] = useState(false); const [subLinksOpen, setSubLinksOpen] = useState(false);
const [bulkGroupOpen, setBulkGroupOpen] = useState(false);
const [bulkAttachOpen, setBulkAttachOpen] = useState(false); const [bulkAttachOpen, setBulkAttachOpen] = useState(false);
const [bulkDetachOpen, setBulkDetachOpen] = useState(false); const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]); const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
@@ -846,27 +802,6 @@ export default function ClientsPage() {
}); });
} }
function onBulkUngroup() {
const emails = [...selectedRowKeys];
if (emails.length === 0) return;
modal.confirm({
title: t('pages.clients.ungroupConfirmTitle', { count: emails.length }),
content: t('pages.clients.ungroupConfirmContent'),
okText: t('confirm'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await bulkRemoveFromGroup(emails);
if (msg?.success) {
setSelectedRowKeys([]);
const affected =
(msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
messageApi.success(t('pages.clients.ungroupSuccessToast', { count: affected }));
}
},
});
}
function onBulkSetEnable(enable: boolean) { function onBulkSetEnable(enable: boolean) {
const emails = [...selectedRowKeys]; const emails = [...selectedRowKeys];
if (emails.length === 0) return; if (emails.length === 0) return;
@@ -1382,19 +1317,6 @@ export default function ClientsPage() {
danger: true, danger: true,
onClick: () => setBulkDetachOpen(true), onClick: () => setBulkDetachOpen(true),
}, },
{
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.clients.addToGroup'),
onClick: () => setBulkGroupOpen(true),
},
{
key: 'ungroup',
icon: <UngroupIcon />,
label: t('pages.clients.ungroup'),
danger: true,
onClick: onBulkUngroup,
},
{ type: 'divider' as const }, { type: 'divider' as const },
{ {
key: 'enable', key: 'enable',
@@ -1912,22 +1834,6 @@ export default function ClientsPage() {
onOpenChange={setSubLinksOpen} onOpenChange={setSubLinksOpen}
/> />
</LazyMount> </LazyMount>
<LazyMount when={bulkGroupOpen}>
<BulkAddToGroupModal
open={bulkGroupOpen}
count={selectedRowKeys.length}
groups={allGroups}
onOpenChange={setBulkGroupOpen}
onSubmit={async (group) => {
const msg = await bulkAddToGroup([...selectedRowKeys], group);
if (msg?.success) {
setSelectedRowKeys([]);
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={bulkAttachOpen}> <LazyMount when={bulkAttachOpen}>
<BulkAttachInboundsModal <BulkAttachInboundsModal
open={bulkAttachOpen} open={bulkAttachOpen}
@@ -1,170 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ClientRecord } from '@/hooks/useClients';
interface GroupAddClientsModalProps {
open: boolean;
groupName: string | null;
candidates: ClientRecord[];
onClose: () => void;
onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
}
interface ClientRow {
email: string;
comment: string;
enable: boolean;
currentGroup: string;
}
export default function GroupAddClientsModal({
open,
groupName,
candidates,
onClose,
onSubmit,
}: GroupAddClientsModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [saving, setSaving] = useState(false);
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
const [search, setSearch] = useState('');
const rows = useMemo<ClientRow[]>(
() =>
(candidates || [])
.map((c) => ({
email: (c.email || '').trim(),
comment: (c.comment || '').trim(),
enable: c.enable !== false,
currentGroup: (c.group || '').trim(),
}))
.filter((r) => r.email),
[candidates],
);
// React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setSelectedEmails([]);
setSearch('');
}
}
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) =>
r.email.toLowerCase().includes(q) ||
r.comment.toLowerCase().includes(q) ||
r.currentGroup.toLowerCase().includes(q),
);
}, [rows, search]);
const columns: ColumnsType<ClientRow> = useMemo(
() => [
{ title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
{ title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
{
title: t('pages.clients.group'),
dataIndex: 'currentGroup',
key: 'currentGroup',
width: 140,
ellipsis: true,
render: (g: string) =>
g ? (
<Tag color="geekblue">{g}</Tag>
) : (
<span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>
),
},
{
title: t('enable'),
dataIndex: 'enable',
key: 'enable',
width: 90,
render: (enabled: boolean) =>
enabled ? (
<Tag color="success">{t('enable')}</Tag>
) : (
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
),
},
],
[t],
);
async function submit() {
if (!groupName || selectedEmails.length === 0) return;
setSaving(true);
try {
const result = await onSubmit(selectedEmails);
if (!result) return;
const affected = result.affected ?? selectedEmails.length;
messageApi.success(t('pages.groups.addToGroupResult', { count: affected, name: groupName }));
onClose();
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onCancel={onClose}
onOk={submit}
okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
okText={t('add')}
cancelText={t('cancel')}
title={t('pages.groups.addToGroupTitle', { name: groupName ?? '' })}
width={720}
>
{messageContextHolder}
<Typography.Paragraph type="secondary">
{t('pages.groups.addToGroupDesc')}
</Typography.Paragraph>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search
allowClear
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
style={{ maxWidth: 320 }}
/>
<Typography.Text type="secondary">
{t('pages.inbounds.attachClientsSelectedCount', {
selected: selectedEmails.length,
total: rows.length,
})}
</Typography.Text>
</Space>
{rows.length === 0 ? (
<Alert type="info" showIcon title={t('pages.groups.addToGroupEmpty')} />
) : (
<Table<ClientRow>
size="small"
rowKey="email"
columns={columns}
dataSource={filteredRows}
pagination={false}
scroll={{ y: 320 }}
rowSelection={{
selectedRowKeys: selectedEmails,
onChange: (keys) => setSelectedEmails(keys as string[]),
preserveSelectedRowKeys: true,
}}
/>
)}
</Space>
</Modal>
);
}
@@ -1,150 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ClientRecord } from '@/hooks/useClients';
interface GroupRemoveClientsModalProps {
open: boolean;
groupName: string | null;
members: ClientRecord[];
onClose: () => void;
onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
}
interface ClientRow {
email: string;
comment: string;
enable: boolean;
}
export default function GroupRemoveClientsModal({
open,
groupName,
members,
onClose,
onSubmit,
}: GroupRemoveClientsModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [saving, setSaving] = useState(false);
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
const [search, setSearch] = useState('');
const rows = useMemo<ClientRow[]>(
() =>
(members || [])
.map((c) => ({
email: (c.email || '').trim(),
comment: (c.comment || '').trim(),
enable: c.enable !== false,
}))
.filter((r) => r.email),
[members],
);
// React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) {
setSelectedEmails([]);
setSearch('');
}
}
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(r) => r.email.toLowerCase().includes(q) || r.comment.toLowerCase().includes(q),
);
}, [rows, search]);
const columns: ColumnsType<ClientRow> = useMemo(
() => [
{ title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
{ title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
{
title: t('enable'),
dataIndex: 'enable',
key: 'enable',
width: 90,
render: (enabled: boolean) =>
enabled ? (
<Tag color="success">{t('enable')}</Tag>
) : (
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
),
},
],
[t],
);
async function submit() {
if (!groupName || selectedEmails.length === 0) return;
setSaving(true);
try {
const result = await onSubmit(selectedEmails);
if (!result) return;
const affected = result.affected ?? selectedEmails.length;
messageApi.success(
t('pages.groups.removeFromGroupResult', { count: affected, name: groupName }),
);
onClose();
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onCancel={onClose}
onOk={submit}
okButtonProps={{ danger: true, disabled: selectedEmails.length === 0, loading: saving }}
okText={t('remove')}
cancelText={t('cancel')}
title={t('pages.groups.removeFromGroupTitle', { name: groupName ?? '' })}
width={680}
>
{messageContextHolder}
<Typography.Paragraph type="secondary">
{t('pages.groups.removeFromGroupDesc')}
</Typography.Paragraph>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search
allowClear
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
style={{ maxWidth: 320 }}
/>
<Typography.Text type="secondary">
{t('pages.inbounds.attachClientsSelectedCount', {
selected: selectedEmails.length,
total: rows.length,
})}
</Typography.Text>
</Space>
<Table<ClientRow>
size="small"
rowKey="email"
columns={columns}
dataSource={filteredRows}
pagination={false}
scroll={{ y: 280 }}
rowSelection={{
selectedRowKeys: selectedEmails,
onChange: (keys) => setSelectedEmails(keys as string[]),
preserveSelectedRowKeys: true,
}}
/>
</Space>
</Modal>
);
}
-708
View File
@@ -1,708 +0,0 @@
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Col,
ConfigProvider,
Dropdown,
Form,
Input,
Layout,
Modal,
Result,
Row,
Space,
Spin,
Statistic,
Table,
Tag,
Tooltip,
message,
} from 'antd';
import type { MenuProps, TableColumnsType } from 'antd';
import {
ArrowDownOutlined,
ArrowUpOutlined,
ClockCircleOutlined,
DeleteOutlined,
EditOutlined,
LinkOutlined,
MoreOutlined,
PieChartOutlined,
PlusOutlined,
RetweetOutlined,
TagsOutlined,
TeamOutlined,
UsergroupAddOutlined,
UsergroupDeleteOutlined,
} from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { usePageTitle } from '@/hooks/usePageTitle';
import { useClients } from '@/hooks/useClients';
import { HttpUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility';
import { keys } from '@/api/queryKeys';
import {
ClientRecordSchema,
GroupSummaryListSchema,
type ClientRecord,
type GroupSummary,
} from '@/schemas/client';
import { parseMsg } from '@/utils/zodValidate';
const ClientRecordListSchema = z
.array(ClientRecordSchema)
.nullable()
.transform((v) => v ?? []);
const SubLinksModal = lazy(() => import('../clients/SubLinksModal'));
const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal'));
const GroupAddClientsModal = lazy(() => import('./GroupAddClientsModal'));
const GroupRemoveClientsModal = lazy(() => import('./GroupRemoveClientsModal'));
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
async function fetchGroups(): Promise<GroupSummary[]> {
const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load groups');
const validated = parseMsg(msg, GroupSummaryListSchema, 'clients/groups');
return validated.obj ?? [];
}
async function fetchEmailsForGroup(name: string): Promise<string[]> {
const msg = await HttpUtil.get<string[]>(
`/panel/api/clients/groups/${encodeURIComponent(name)}/emails`,
undefined,
{ silent: true },
);
if (!msg?.success || !Array.isArray(msg.obj)) return [];
return msg.obj;
}
export default function GroupsPage() {
usePageTitle();
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const queryClient = useQueryClient();
const { subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients({
list: false,
});
const groupsQuery = useQuery({
queryKey: keys.clients.groups(),
queryFn: fetchGroups,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const loading = groupsQuery.isFetching;
const fetched = groupsQuery.data !== undefined || groupsQuery.isError;
const fetchError = groupsQuery.error ? (groupsQuery.error as Error).message : '';
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: keys.clients.root() });
}, [queryClient]);
const createMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/create', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const renameMut = useMutation({
mutationFn: (body: { oldName: string; newName: string }) =>
HttpUtil.post('/panel/api/clients/groups/rename', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const deleteMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/delete', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const groupResetMut = useMutation({
mutationFn: (body: { name: string }) =>
HttpUtil.post('/panel/api/clients/groups/resetTraffic', body, JSON_HEADERS),
onSuccess: (msg) => {
if (msg?.success) invalidate();
},
});
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState('');
const [renameOpen, setRenameOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<GroupSummary | null>(null);
const [renameValue, setRenameValue] = useState('');
const [subLinksOpen, setSubLinksOpen] = useState(false);
const [adjustOpen, setAdjustOpen] = useState(false);
const [addClientsOpen, setAddClientsOpen] = useState(false);
const [removeClientsOpen, setRemoveClientsOpen] = useState(false);
const [groupEmails, setGroupEmails] = useState<string[]>([]);
const [groupForAction, setGroupForAction] = useState<GroupSummary | null>(null);
const allClientsQuery = useQuery<ClientRecord[]>({
queryKey: keys.clients.all(),
queryFn: async () => {
const msg = await HttpUtil.get('/panel/api/clients/list', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load clients');
const validated = parseMsg(msg, ClientRecordListSchema, 'clients/list');
return validated.obj ?? [];
},
enabled: addClientsOpen || removeClientsOpen || subLinksOpen,
staleTime: 30_000,
});
const allClients = allClientsQuery.data ?? [];
const totalGroups = groups.length;
const totalClients = useMemo(
() => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
[groups],
);
const totalTraffic = useMemo(
() => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
[groups],
);
const totalUpload = useMemo(() => groups.reduce((acc, g) => acc + (g.up || 0), 0), [groups]);
const totalDownload = useMemo(() => groups.reduce((acc, g) => acc + (g.down || 0), 0), [groups]);
function openCreate() {
setCreateName('');
setCreateOpen(true);
}
async function confirmCreate() {
const name = createName.trim();
if (!name) return;
if (groups.some((g) => g.name.toLowerCase() === name.toLowerCase())) {
messageApi.error(t('pages.groups.renameCollision', { name }));
return;
}
const msg = await createMut.mutateAsync({ name });
if (msg?.success) {
messageApi.success(t('pages.groups.createSuccess', { name }));
setCreateOpen(false);
}
}
function openRename(g: GroupSummary) {
setRenameTarget(g);
setRenameValue(g.name);
setRenameOpen(true);
}
async function confirmRename() {
if (!renameTarget) return;
const next = renameValue.trim();
if (!next || next === renameTarget.name) {
setRenameOpen(false);
return;
}
if (
groups.some(
(g) => g.name.toLowerCase() === next.toLowerCase() && g.name !== renameTarget.name,
)
) {
messageApi.error(t('pages.groups.renameCollision', { name: next }));
return;
}
const msg = await renameMut.mutateAsync({ oldName: renameTarget.name, newName: next });
if (msg?.success) {
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
messageApi.success(t('pages.groups.renameSuccess', { count: affected }));
setRenameOpen(false);
}
}
function onDelete(g: GroupSummary) {
modal.confirm({
title: t('pages.groups.deleteConfirmTitle', { name: g.name }),
content: t('pages.groups.deleteConfirmContent', { count: g.clientCount }),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await deleteMut.mutateAsync({ name: g.name });
if (msg?.success) {
const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? 0;
messageApi.success(t('pages.groups.deleteSuccess', { count: affected }));
}
},
});
}
async function openSubLinksFor(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
const emails = await fetchEmailsForGroup(g.name);
if (emails.length === 0) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
setGroupForAction(g);
setGroupEmails(emails);
setSubLinksOpen(true);
}
async function openAdjustFor(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
const emails = await fetchEmailsForGroup(g.name);
if (emails.length === 0) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
setGroupForAction(g);
setGroupEmails(emails);
setAdjustOpen(true);
}
function openAddClientsFor(g: GroupSummary) {
setGroupForAction(g);
setAddClientsOpen(true);
}
function openRemoveClientsFor(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
setGroupForAction(g);
setRemoveClientsOpen(true);
}
function onDeleteClients(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
modal.confirm({
title: t('pages.groups.deleteClientsConfirmTitle', { name: g.name }),
content: t('pages.groups.deleteClientsConfirmContent', { count: g.clientCount }),
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const emails = await fetchEmailsForGroup(g.name);
if (emails.length === 0) return;
const msg = await bulkDelete(emails);
if (msg?.success) {
const ok = msg.obj?.deleted ?? 0;
const skipped = msg.obj?.skipped ?? [];
const failed = skipped.length;
if (failed === 0) {
messageApi.success(t('pages.groups.deleteClientsSuccess', { count: ok }));
} else {
const firstError = skipped[0]?.reason ?? msg?.msg ?? '';
messageApi.warning(
firstError
? `${t('pages.groups.deleteClientsMixed', { ok, failed })} — ${firstError}`
: t('pages.groups.deleteClientsMixed', { ok, failed }),
);
}
}
},
});
}
function onResetTraffic(g: GroupSummary) {
if (!g.clientCount) {
messageApi.info(t('pages.groups.emptyForAction'));
return;
}
modal.confirm({
title: t('pages.groups.resetConfirmTitle', { name: g.name }),
content: t('pages.groups.resetConfirmContent'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: async () => {
const msg = await groupResetMut.mutateAsync({ name: g.name });
if (msg?.success) {
messageApi.success(t('pages.groups.resetSuccess', { name: g.name }));
}
},
});
}
function rowActions(row: GroupSummary): MenuProps['items'] {
return [
{
key: 'subLinks',
icon: <LinkOutlined />,
label: t('pages.clients.subLinksSelected', { count: row.clientCount || 0 }),
disabled: !row.clientCount,
onClick: () => openSubLinksFor(row),
},
{
key: 'adjust',
icon: <ClockCircleOutlined />,
label: t('pages.clients.adjustSelected', { count: row.clientCount || 0 }),
disabled: !row.clientCount,
onClick: () => openAdjustFor(row),
},
{
key: 'reset',
icon: <RetweetOutlined />,
label: t('pages.groups.resetTraffic'),
disabled: !row.clientCount,
onClick: () => onResetTraffic(row),
},
{
key: 'addClients',
icon: <UsergroupAddOutlined />,
label: t('pages.groups.addToGroup'),
onClick: () => openAddClientsFor(row),
},
{
key: 'rename',
icon: <EditOutlined />,
label: t('pages.groups.rename'),
onClick: () => openRename(row),
},
{ type: 'divider' },
{
key: 'removeClients',
icon: <UsergroupDeleteOutlined />,
label: t('pages.groups.removeFromGroup'),
danger: true,
disabled: !row.clientCount,
onClick: () => openRemoveClientsFor(row),
},
{
key: 'deleteClients',
icon: <DeleteOutlined />,
label: t('pages.groups.deleteClients'),
danger: true,
disabled: !row.clientCount,
onClick: () => onDeleteClients(row),
},
{
key: 'delete',
icon: <DeleteOutlined />,
label: t('pages.groups.deleteGroupOnly'),
danger: true,
onClick: () => onDelete(row),
},
];
}
const columns: TableColumnsType<GroupSummary> = [
{
title: t('pages.clients.actions'),
key: 'actions',
width: 90,
render: (_v, row) => (
<Space size={4}>
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
<Button
aria-label={t('more')}
size="small"
type="text"
style={{ fontSize: 16 }}
icon={<MoreOutlined />}
/>
</Dropdown>
<Tooltip title={t('pages.groups.rename')}>
<Button
aria-label={t('pages.groups.rename')}
size="small"
type="text"
style={{ fontSize: 16 }}
icon={<EditOutlined />}
onClick={() => openRename(row)}
/>
</Tooltip>
</Space>
),
},
{
title: t('pages.groups.name'),
dataIndex: 'name',
key: 'name',
render: (name: string) => (
<Tag color="geekblue" style={{ margin: 0, fontSize: 13 }}>
{name}
</Tag>
),
},
{
title: t('pages.groups.clientCount'),
dataIndex: 'clientCount',
key: 'clientCount',
width: 180,
render: (count: number) => <span>{count || 0}</span>,
},
{
title: t('pages.groups.upload'),
dataIndex: 'up',
key: 'up',
width: 140,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
{
title: t('pages.groups.download'),
dataIndex: 'down',
key: 'down',
width: 140,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
{
title: t('pages.groups.trafficUsed'),
dataIndex: 'trafficUsed',
key: 'trafficUsed',
width: 160,
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
},
];
const pageClass = useMemo(() => {
const classes = ['groups-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={!fetched} delay={200} description={t('loading')} size="large">
{!fetched ? (
<div className="loading-spacer" />
) : fetchError ? (
<Result
status="error"
title={t('somethingWentWrong')}
subTitle={fetchError}
extra={
<Button type="primary" loading={loading} onClick={() => groupsQuery.refetch()}>
{t('refresh')}
</Button>
}
/>
) : (
<Row gutter={[isMobile ? 8 : 16, isMobile ? 8 : 12]}>
<Col span={24}>
<Card size="small" hoverable className="summary-card">
<Row gutter={[16, isMobile ? 16 : 12]}>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalGroups')}
value={String(totalGroups)}
prefix={<TagsOutlined />}
/>
</Col>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalGroupedClients')}
value={String(totalClients)}
prefix={<TeamOutlined />}
/>
</Col>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalUpDown')}
value={0}
formatter={() => (
<span>
<ArrowUpOutlined /> {SizeFormatter.sizeFormat(totalUpload)}
{' / '}
<ArrowDownOutlined /> {SizeFormatter.sizeFormat(totalDownload)}
</span>
)}
/>
</Col>
<Col xs={12} sm={12} md={6}>
<Statistic
title={t('pages.groups.totalTraffic')}
value={SizeFormatter.sizeFormat(totalTraffic)}
prefix={<PieChartOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<Card
size="small"
hoverable
title={
<div className="card-toolbar">
<Button
aria-label={t('pages.groups.addGroup')}
type="primary"
icon={<PlusOutlined />}
onClick={openCreate}
>
{!isMobile && t('pages.groups.addGroup')}
</Button>
</div>
}
>
<Table<GroupSummary>
dataSource={groups}
columns={columns}
rowKey="name"
size="small"
pagination={false}
loading={loading}
locale={{
emptyText: (
<div className="card-empty">
<TagsOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<div>{t('noData')}</div>
</div>
),
}}
/>
</Card>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<Modal
open={createOpen}
title={t('pages.groups.addGroup')}
okText={t('create')}
cancelText={t('cancel')}
confirmLoading={createMut.isPending}
onCancel={() => setCreateOpen(false)}
onOk={confirmCreate}
destroyOnHidden
>
<Form layout="vertical">
<Form.Item label={t('pages.groups.name')}>
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onPressEnter={confirmCreate}
placeholder={t('pages.clients.groupPlaceholder')}
autoFocus
/>
</Form.Item>
</Form>
</Modal>
<Modal
open={renameOpen}
title={renameTarget ? t('pages.groups.renameTitle', { name: renameTarget.name }) : ''}
okText={t('save')}
cancelText={t('cancel')}
confirmLoading={renameMut.isPending}
onCancel={() => setRenameOpen(false)}
onOk={confirmRename}
destroyOnHidden
>
<Form layout="vertical">
<Form.Item label={t('pages.groups.name')}>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onPressEnter={confirmRename}
placeholder={t('pages.clients.groupPlaceholder')}
autoFocus
/>
</Form.Item>
</Form>
</Modal>
<LazyMount when={subLinksOpen}>
<SubLinksModal
open={subLinksOpen}
emails={groupEmails}
clients={allClients}
subSettings={subSettings}
onOpenChange={setSubLinksOpen}
/>
</LazyMount>
<LazyMount when={adjustOpen}>
<ClientBulkAdjustModal
open={adjustOpen}
count={groupEmails.length}
onOpenChange={setAdjustOpen}
onSubmit={async (addDays, addBytes) => {
const msg = await bulkAdjust(groupEmails, addDays, addBytes);
if (msg?.success) {
const obj = msg.obj ?? { adjusted: 0 };
messageApi.success(
t('pages.groups.adjustSuccess', {
count: obj.adjusted ?? 0,
name: groupForAction?.name ?? '',
}),
);
return obj;
}
return null;
}}
/>
</LazyMount>
<LazyMount when={addClientsOpen}>
<GroupAddClientsModal
open={addClientsOpen}
groupName={groupForAction?.name ?? null}
candidates={allClients.filter((c) => c.group !== groupForAction?.name)}
onClose={() => setAddClientsOpen(false)}
onSubmit={async (emails) => {
const msg = await bulkAddToGroup(emails, groupForAction?.name ?? '');
if (msg?.success) {
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}
return null;
}}
/>
</LazyMount>
<LazyMount when={removeClientsOpen}>
<GroupRemoveClientsModal
open={removeClientsOpen}
groupName={groupForAction?.name ?? null}
members={allClients.filter((c) => c.group === groupForAction?.name)}
onClose={() => setRemoveClientsOpen(false)}
onSubmit={async (emails) => {
const msg = await bulkRemoveFromGroup(emails);
if (msg?.success) {
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}
return null;
}}
/>
</LazyMount>
</Layout>
</ConfigProvider>
);
}
@@ -53,7 +53,6 @@ const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal')); const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal')); const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal')); const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
type RowAction = type RowAction =
| 'edit' | 'edit'
@@ -68,7 +67,6 @@ type RowAction =
| 'attachClients' | 'attachClients'
| 'attachExisting' | 'attachExisting'
| 'detachClients' | 'detachClients'
| 'addToGroup'
| 'clone'; | 'clone';
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds'; type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
@@ -165,9 +163,6 @@ export default function InboundsPage() {
const [detachOpen, setDetachOpen] = useState(false); const [detachOpen, setDetachOpen] = useState(false);
const [detachSource, setDetachSource] = useState<DBInbound | null>(null); const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
const [groupOpen, setGroupOpen] = useState(false);
const [groupSource, setGroupSource] = useState<DBInbound | null>(null);
const [cloneOpen, setCloneOpen] = useState(false); const [cloneOpen, setCloneOpen] = useState(false);
const [cloneSource, setCloneSource] = useState<DBInbound | null>(null); const [cloneSource, setCloneSource] = useState<DBInbound | null>(null);
@@ -622,7 +617,6 @@ export default function InboundsPage() {
'clipboard', 'clipboard',
'clone', 'clone',
'attachClients', 'attachClients',
'addToGroup',
]; ];
let target = dbInbound; let target = dbInbound;
if (hydratingKeys.includes(key)) { if (hydratingKeys.includes(key)) {
@@ -672,10 +666,6 @@ export default function InboundsPage() {
setDetachSource(target); setDetachSource(target);
setDetachOpen(true); setDetachOpen(true);
break; break;
case 'addToGroup':
setGroupSource(target);
setGroupOpen(true);
break;
case 'clone': case 'clone':
confirmClone(target); confirmClone(target);
break; break;
@@ -848,14 +838,6 @@ export default function InboundsPage() {
source={detachSource} source={detachSource}
/> />
</LazyMount> </LazyMount>
<LazyMount when={groupOpen}>
<AddClientsToGroupModal
open={groupOpen}
onClose={() => setGroupOpen(false)}
onAdded={refresh}
source={groupSource}
/>
</LazyMount>
<LazyMount when={cloneOpen}> <LazyMount when={cloneOpen}>
<CloneInboundModal <CloneInboundModal
open={cloneOpen} open={cloneOpen}
@@ -1,65 +0,0 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { HttpUtil } from '@/utils';
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
const BulkAddToGroupModal = lazy(() => import('@/pages/clients/BulkAddToGroupModal'));
interface AddClientsToGroupModalProps {
open: boolean;
source: DBInbound | null;
onClose: () => void;
onAdded?: () => void;
}
function readClientEmails(settings: unknown): string[] {
const parsed = coerceInboundJsonField(settings) as { clients?: Array<{ email?: string }> };
const clients = Array.isArray(parsed?.clients) ? parsed.clients : [];
return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
}
export default function AddClientsToGroupModal({
open,
source,
onClose,
onAdded,
}: AddClientsToGroupModalProps) {
const [groups, setGroups] = useState<string[]>([]);
const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
const msg = await HttpUtil.get('/panel/api/clients/groups', undefined, { silent: true });
if (cancelled) return;
const list = Array.isArray(msg?.obj) ? (msg.obj as Array<{ name?: string }>) : [];
setGroups(list.map((g) => g?.name || '').filter(Boolean));
})();
return () => {
cancelled = true;
};
}, [open]);
return (
<BulkAddToGroupModal
open={open}
count={emails.length}
groups={groups}
onOpenChange={(o) => {
if (!o) onClose();
}}
onSubmit={async (group) => {
const msg = await HttpUtil.post(
'/panel/api/clients/groups/bulkAdd',
{ emails, group },
{ headers: { 'Content-Type': 'application/json' } },
);
if (!msg?.success) return null;
onAdded?.();
return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
}}
/>
);
}
@@ -0,0 +1,173 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useFormContext, useWatch } from 'react-hook-form';
import { Alert, Button, Form, Input, InputNumber, Radio, Select, Space } from 'antd';
import { FormField } from '@/components/form/rhf';
import { HttpUtil } from '@/utils';
import type { ForwardingSpec } from '@/schemas/forms/inbound-form';
interface ForwardTarget {
id: number;
remark: string;
tag: string;
protocol: string;
listen: string;
port: number;
enable: boolean;
nodeId: number | null;
nodeName: string;
}
// Parse a landing link into manual-forwarding fields. Accepts the plain
// URL form (socks5://user:pass@host:port, also socks:// and http://) and
// the whole-link base64 variant common in share links.
export function parseLandingLink(raw: string): Partial<ForwardingSpec> | null {
const link = raw.trim();
if (!link) return null;
const tryUrl = (s: string): Partial<ForwardingSpec> | null => {
try {
const u = new URL(s);
if (!['socks5:', 'socks:', 'http:', 'https:'].includes(u.protocol)) return null;
const port = u.port ? parseInt(u.port, 10) : 0;
if (!u.hostname || !port) return null;
return {
protocol: u.protocol === 'http:' || u.protocol === 'https:' ? 'http' : 'socks5',
address: u.hostname,
port,
username: decodeURIComponent(u.username || ''),
password: decodeURIComponent(u.password || ''),
};
} catch {
return null;
}
};
const direct = tryUrl(link);
if (direct) return direct;
try {
const decoded = atob(link.replace(/-/g, '+').replace(/_/g, '/'));
return tryUrl(decoded.includes('://') ? decoded : `socks5://${decoded}`);
} catch {
return null;
}
}
export default function ForwardingTab({ currentId }: { currentId?: number }) {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const mode = useWatch({ control, name: 'forwarding.mode' });
const landing = useWatch({ control, name: 'forwarding.landing' });
const [targets, setTargets] = useState<ForwardTarget[]>([]);
const [link, setLink] = useState('');
const [linkError, setLinkError] = useState('');
useEffect(() => {
let alive = true;
HttpUtil.get('/panel/api/inbounds/forwardTargets')
.then((msg) => {
if (alive && msg?.success && Array.isArray(msg.obj)) {
setTargets(msg.obj as ForwardTarget[]);
}
})
.catch(() => {});
return () => {
alive = false;
};
}, []);
const applyLink = () => {
const parsed = parseLandingLink(link);
if (!parsed) {
setLinkError(t('pages.inbounds.forwarding.importFailed'));
return;
}
setLinkError('');
const opts = { shouldDirty: true, shouldValidate: true };
setValue('forwarding.protocol', parsed.protocol ?? 'socks5', opts);
setValue('forwarding.address', parsed.address ?? '', opts);
setValue('forwarding.port', parsed.port ?? 0, opts);
setValue('forwarding.username', parsed.username ?? '', opts);
setValue('forwarding.password', parsed.password ?? '', opts);
};
return (
<>
<FormField name="forwarding.mode" label={t('pages.inbounds.forwarding.mode')}>
<Radio.Group
options={[
{ value: 'direct', label: t('pages.inbounds.forwarding.direct') },
{ value: 'forward', label: t('pages.inbounds.forwarding.forward') },
]}
/>
</FormField>
{mode === 'forward' && (
<>
<FormField name="forwarding.landing" label={t('pages.inbounds.forwarding.landing')}>
<Radio.Group
options={[
{ value: 'managed', label: t('pages.inbounds.forwarding.managed') },
{ value: 'manual', label: t('pages.inbounds.forwarding.manual') },
]}
/>
</FormField>
{landing === 'managed' ? (
<FormField
name="forwarding.targetInboundId"
label={t('pages.inbounds.forwarding.target')}
>
<Select
showSearch
placeholder={t('pages.inbounds.forwarding.targetPlaceholder')}
options={targets
.filter((x) => x.id !== currentId)
.map((x) => ({
value: x.id,
disabled: !x.enable,
label: `${x.nodeName || t('pages.inbounds.localPanel')} · ${x.remark || x.tag} (${x.listen || '*'}:${x.port})${x.enable ? '' : ` (${t('pages.inbounds.forwarding.targetDisabled')})`}`,
}))}
/>
</FormField>
) : (
<>
<FormField name="forwarding.address" label={t('pages.inbounds.forwarding.address')}>
<Input placeholder="10.240.64.2" />
</FormField>
<FormField name="forwarding.port" label={t('pages.inbounds.forwarding.port')}>
<InputNumber min={1} max={65535} />
</FormField>
<FormField name="forwarding.protocol" label={t('pages.inbounds.forwarding.protocol')}>
<Select
options={[
{ value: 'socks5', label: 'SOCKS5' },
{ value: 'http', label: 'HTTP' },
]}
/>
</FormField>
<FormField name="forwarding.username" label={t('pages.inbounds.forwarding.username')}>
<Input autoComplete="off" />
</FormField>
<FormField name="forwarding.password" label={t('pages.inbounds.forwarding.password')}>
<Input.Password autoComplete="off" />
</FormField>
<Form.Item label={t('pages.inbounds.forwarding.importLink')}>
<Space.Compact style={{ width: '100%' }}>
<Input
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder={t('pages.inbounds.forwarding.importPlaceholder')}
/>
<Button onClick={applyLink}>{t('pages.inbounds.forwarding.importApply')}</Button>
</Space.Compact>
{linkError && <div style={{ color: '#ff4d4f', marginTop: 4 }}>{linkError}</div>}
</Form.Item>
</>
)}
<Alert type="info" showIcon message={t('pages.inbounds.forwarding.help')} />
</>
)}
</>
);
}
@@ -82,6 +82,7 @@ import { useSecurityActions } from './useSecurityActions';
import { useInboundFallbacks } from './useInboundFallbacks'; import { useInboundFallbacks } from './useInboundFallbacks';
import FallbacksCard from './FallbacksCard'; import FallbacksCard from './FallbacksCard';
import SniffingTab from './SniffingTab'; import SniffingTab from './SniffingTab';
import ForwardingTab from './ForwardingTab';
import type { DBInbound } from '@/models/dbinbound'; import type { DBInbound } from '@/models/dbinbound';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { NodeRecord } from '@/api/queries/useNodesQuery';
@@ -150,6 +151,7 @@ function firstRhfValidationIssue(
function tabForValidationPath(path: PropertyKey[]): string { function tabForValidationPath(path: PropertyKey[]): string {
if (path[0] === 'settings') return 'protocol'; if (path[0] === 'settings') return 'protocol';
if (path[0] === 'sniffing') return 'sniffing'; if (path[0] === 'sniffing') return 'sniffing';
if (path[0] === 'forwarding') return 'forwarding';
if (path[0] === 'streamSettings') { if (path[0] === 'streamSettings') {
if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings') if (path[1] === 'security' || path[1] === 'realitySettings' || path[1] === 'tlsSettings')
return 'security'; return 'security';
@@ -618,7 +620,6 @@ export default function InboundFormModal({
<FormField name="nodeId" label={t('pages.inbounds.deployTo')}> <FormField name="nodeId" label={t('pages.inbounds.deployTo')}>
<Select <Select
showSearch showSearch
disabled={mode === 'edit'}
placeholder={t('pages.inbounds.localPanel')} placeholder={t('pages.inbounds.localPanel')}
allowClear allowClear
options={selectableNodes.map((n) => ({ options={selectableNodes.map((n) => ({
@@ -1079,6 +1080,10 @@ export default function InboundFormModal({
const sniffingTab = <SniffingTab />; const sniffingTab = <SniffingTab />;
const forwardingTab = (
<ForwardingTab currentId={mode === 'edit' && dbInbound ? dbInbound.id : undefined} />
);
return ( return (
<> <>
{messageContextHolder} {messageContextHolder}
@@ -1112,6 +1117,12 @@ export default function InboundFormModal({
children: basicTab, children: basicTab,
forceRender: true, forceRender: true,
}, },
{
key: 'forwarding',
label: t('pages.inbounds.forwardingTab'),
children: forwardingTab,
forceRender: true,
},
...(( ...((
[ [
Protocols.VLESS, Protocols.VLESS,
@@ -180,3 +180,11 @@
padding: 4px; padding: 4px;
} }
} }
.forwarding-tag {
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
@@ -137,11 +137,17 @@ export default function InboundList({
if (ok) setSelectedRowKeys([]); if (ok) setSelectedRowKeys([]);
}, [onBulkDelete, selectedRowKeys]); }, [onBulkDelete, selectedRowKeys]);
const inboundsById = useMemo(
() => new Map(dbInbounds.map((ib) => [ib.id, ib] as const)),
[dbInbounds],
);
const columns = useInboundColumns({ const columns = useInboundColumns({
hasAnyRemark, hasAnyRemark,
hasAnySubSortIndex, hasAnySubSortIndex,
hasActiveNode, hasActiveNode,
nodesById, nodesById,
inboundsById,
clientCount, clientCount,
inboundSpeed, inboundSpeed,
subEnable, subEnable,
@@ -10,7 +10,6 @@ import {
BlockOutlined, BlockOutlined,
DeleteOutlined, DeleteOutlined,
InfoCircleOutlined, InfoCircleOutlined,
TagsOutlined,
UsergroupAddOutlined, UsergroupAddOutlined,
UsergroupDeleteOutlined, UsergroupDeleteOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -91,11 +90,6 @@ export function buildRowActionsMenu({
icon: <UsergroupDeleteOutlined />, icon: <UsergroupDeleteOutlined />,
label: t('pages.inbounds.detachClients'), label: t('pages.inbounds.detachClients'),
}); });
items.push({
key: 'addToGroup',
icon: <TagsOutlined />,
label: t('pages.inbounds.addClientsToGroup'),
});
items.push({ type: 'divider' }); items.push({ type: 'divider' });
items.push({ items.push({
key: 'delAllClients', key: 'delAllClients',
@@ -90,6 +90,8 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
case 'mtproto': case 'mtproto':
case 'wireguard': case 'wireguard':
case 'amneziawg': case 'amneziawg':
case 'socks':
case 'mixed':
return true; return true;
case 'shadowsocks': case 'shadowsocks':
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) }); return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
@@ -104,3 +106,26 @@ export function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
} }
return false; return false;
} }
// Landing label for the list-row forwarding chip: the managed target's
// remark (falling back to its tag), or the manual endpoint address:port.
// Returns null when the inbound does not forward, so the caller renders
// no chip. The stored spec rides the row as a JSON string.
export function forwardingLandingLabel(
record: DBInboundRecord,
inboundsById: Map<number, DBInboundRecord>,
): string | null {
const spec = coerceInboundJsonField(record.forwarding) as {
mode?: string;
landing?: string;
targetInboundId?: number;
address?: string;
port?: number;
};
if (spec.mode !== 'forward') return null;
if (spec.landing === 'manual') {
return spec.address ? `${spec.address}:${spec.port ?? 0}` : 'SK5';
}
const target = spec.targetInboundId ? inboundsById.get(spec.targetInboundId) : undefined;
return target?.remark || target?.tag || 'SK5';
}
@@ -32,8 +32,10 @@ export interface DBInboundRecord extends ProtocolFlags {
expiryTime: number; expiryTime: number;
_expiryTime: { valueOf(): number } | null; _expiryTime: { valueOf(): number } | null;
nodeId?: number | null; nodeId?: number | null;
tag?: string;
settings: unknown; settings: unknown;
streamSettings: unknown; streamSettings: unknown;
forwarding?: unknown;
} }
export interface ClientCountEntry { export interface ClientCountEntry {
@@ -17,6 +17,7 @@ import {
} from '@/components/utility/speedTagStyle'; } from '@/components/utility/speedTagStyle';
import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag'; import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
import { import {
forwardingLandingLabel,
readStreamHints, readStreamHints,
networkLabel, networkLabel,
networkL4, networkL4,
@@ -31,6 +32,7 @@ interface UseInboundColumnsParams {
hasAnySubSortIndex: boolean; hasAnySubSortIndex: boolean;
hasActiveNode: boolean; hasActiveNode: boolean;
nodesById: Map<number, NodeRecord>; nodesById: Map<number, NodeRecord>;
inboundsById: Map<number, DBInboundRecord>;
clientCount: Record<number, ClientCountEntry>; clientCount: Record<number, ClientCountEntry>;
inboundSpeed: Record<number, InboundSpeedEntry>; inboundSpeed: Record<number, InboundSpeedEntry>;
subEnable: boolean; subEnable: boolean;
@@ -45,6 +47,7 @@ export function useInboundColumns({
hasAnySubSortIndex, hasAnySubSortIndex,
hasActiveNode, hasActiveNode,
nodesById, nodesById,
inboundsById,
clientCount, clientCount,
inboundSpeed, inboundSpeed,
subEnable, subEnable,
@@ -257,6 +260,17 @@ export function useInboundColumns({
</Tag>, </Tag>,
); );
} }
const fwdLabel = forwardingLandingLabel(record, inboundsById);
if (fwdLabel) {
const text = `${t('pages.inbounds.forwardingTab')} → ${fwdLabel}`;
tags.push(
<Tooltip key="fwd" title={text}>
<Tag color="volcano" className="forwarding-tag">
{text}
</Tag>
</Tooltip>,
);
}
return <div className="protocol-tags">{tags}</div>; return <div className="protocol-tags">{tags}</div>;
}, },
}, },
@@ -453,6 +467,7 @@ export function useInboundColumns({
hasAnySubSortIndex, hasAnySubSortIndex,
hasActiveNode, hasActiveNode,
nodesById, nodesById,
inboundsById,
clientCount, clientCount,
inboundSpeed, inboundSpeed,
subEnable, subEnable,
@@ -1,17 +0,0 @@
.awglog-events-title {
margin-top: 14px;
font-size: 12px;
font-weight: 600;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.awglog-event-line {
padding: 2px 0;
word-break: break-word;
}
.xraylog-table .log-row-offline {
opacity: 0.6;
}
@@ -1,254 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Empty, Form, Input, Modal, Select, Tag } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil, SizeFormatter } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import type { AmneziaWGLogs } from '@/generated/types';
import './XrayLogModal.css';
import './AmneziaWGLogModal.css';
interface AmneziaWGLogModalProps {
open: boolean;
onClose: () => void;
}
const AUTO_UPDATE_INTERVAL = 5000;
function shortTime(value?: number): string {
if (!value) return '';
const d = new Date(value);
if (isNaN(d.getTime())) return '';
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery();
const [rows, setRows] = useState('50');
const [filter, setFilter] = useState('');
const [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<Partial<AmneziaWGLogs>>({});
const peers = useMemo(() => logs.peers ?? [], [logs.peers]);
const events = useMemo(() => logs.events ?? [], [logs.events]);
const runRefresh = useCallback(async () => {
try {
const msg = await HttpUtil.post<AmneziaWGLogs>(`/panel/api/server/amneziawglogs/${rows}`, {
filter,
});
if (msg?.success) setLogs(msg.obj || {});
await PromiseUtil.sleep(300);
} finally {
setLoading(false);
}
}, [rows, filter]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
});
// The spinner is raised during render so the fetch effect stays side-effect
// free until its response lands.
const refreshKey = open ? `${rows}|${filter}` : null;
const [loadingKey, setLoadingKey] = useState<string | null>(null);
if (refreshKey !== loadingKey) {
setLoadingKey(refreshKey);
if (refreshKey) setLoading(true);
}
useEffect(() => {
if (open) void runRefresh();
}, [open, rows, filter, runRefresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
return () => clearInterval(id);
}, [open, autoUpdate]);
function fullDate(value?: number): string {
return value ? IntlUtil.formatDate(value, datepicker) : '';
}
function download() {
const peerLines = peers.map((p) => {
const at = p.handshake ? new Date(p.handshake).toISOString() : 'never';
return `${at} IFACE=${p.interface || ''} INBOUND=${p.tag || ''} EMAIL=${p.email || ''} ENDPOINT=${p.endpoint || '-'} ALLOWEDIPS=${p.allowedIPs || ''} UP=${p.up ?? 0} DOWN=${p.down ?? 0} ONLINE=${p.online ? 'yes' : 'no'}`;
});
FileManager.downloadTextFile([...peerLines, '', ...events].join('\n'), 'amneziawg.log');
}
return (
<Modal
open={open}
footer={null}
width={isMobile ? '100vw' : '80vw'}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
className={isMobile ? 'xraylog-modal-mobile' : undefined}
onCancel={onClose}
title={
<>
{t('pages.index.amneziawgLogs')}
<SyncOutlined
spin={loading}
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('refresh')}
onClick={refresh}
onKeyDown={activateOnKey(refresh)}
/>
</>
}
>
<Form layout="inline" className="log-toolbar">
<Form.Item>
<Select
value={rows}
size="small"
style={{ width: 70 }}
onChange={setRows}
options={[
{ value: '20', label: '20' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
{ value: '500', label: '500' },
]}
/>
</Form.Item>
<Form.Item label={t('filter')} className="filter-item">
<Input
value={filter}
size="small"
onChange={(e) => setFilter(e.target.value)}
onKeyUp={(e) => {
if (e.key === 'Enter') refresh();
}}
/>
</Form.Item>
<Form.Item>
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
{t('pages.index.autoUpdate')}
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
aria-label={t('download')}
/>
</Form.Item>
</Form>
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
{peers.length === 0 ? (
<div className="log-empty">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('pages.index.amneziawgNoPeers')}
/>
</div>
) : isMobile ? (
peers.map((peer, idx) => (
<div key={idx} className="log-card">
<div className="log-card-head">
<span className="log-time" title={fullDate(peer.handshake)}>
{shortTime(peer.handshake) || '—'}
</span>
<Tag color={peer.online ? 'green' : 'default'} className="log-event-tag">
{peer.online ? t('online') : t('pages.index.amneziawgIdle')}
</Tag>
</div>
<div className="log-route">
<span className="log-addr">{peer.endpoint || '—'}</span>
<span className="log-arrow">→</span>
<span className="log-addr">{peer.allowedIPs}</span>
</div>
<div className="log-meta">
<span className="log-meta-pair">
<span className="log-meta-key">iface</span>
<span className="log-meta-val">{peer.interface}</span>
</span>
<span className="log-meta-pair">
<span className="log-meta-key">inbound</span>
<span className="log-meta-val">{peer.tag}</span>
</span>
{peer.email && (
<span className="log-meta-pair">
<span className="log-meta-key">email</span>
<span className="log-meta-val">{peer.email}</span>
</span>
)}
<span className="log-meta-pair">
<span className="log-meta-key">↑↓</span>
<span className="log-meta-val">
{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}
</span>
</span>
</div>
</div>
))
) : (
<table className="xraylog-table">
<thead>
<tr>
<th>{t('pages.index.amneziawgHandshake')}</th>
<th>{t('pages.index.amneziawgInterface')}</th>
<th>{t('pages.index.amneziawgInbound')}</th>
<th>Email</th>
<th>{t('pages.index.amneziawgEndpoint')}</th>
<th>{t('pages.clients.amneziaWgAllowedIPs')}</th>
<th>↑ / ↓</th>
</tr>
</thead>
<tbody>
{peers.map((peer, idx) => (
<tr key={idx} className={peer.online ? undefined : 'log-row-offline'}>
<td>
<b>{fullDate(peer.handshake) || '—'}</b>
</td>
<td>{peer.interface}</td>
<td>{peer.tag}</td>
<td>{peer.email}</td>
<td>{peer.endpoint || '—'}</td>
<td>{peer.allowedIPs}</td>
<td>{`${SizeFormatter.sizeFormat(peer.up ?? 0)} / ${SizeFormatter.sizeFormat(peer.down ?? 0)}`}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="awglog-events-title">{t('pages.index.amneziawgEvents')}</div>
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
{events.length === 0 ? (
<div className="log-empty">{t('pages.index.amneziawgNoEvents')}</div>
) : (
events.map((line, idx) => (
<div key={idx} className="awglog-event-line">
{line}
</div>
))
)}
</div>
</Modal>
);
}
-228
View File
@@ -1,228 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
interface GeodataAssetRow {
url: string;
file: string;
}
interface GeodataSectionProps {
active: boolean;
onBusy: (e: { busy: boolean; tip?: string }) => void;
onClose: () => void;
}
const DEFAULT_CRON = '0 4 * * *';
// Xray resolves `file` inside its asset directory; plain file names only.
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
function fileNameFromUrl(url: string): string {
try {
const seg = new URL(url).pathname.split('/').filter(Boolean).pop() || '';
return FILE_NAME_PATTERN.test(seg) ? seg : '';
} catch {
return '';
}
}
export default function GeodataSection({ active, onBusy, onClose }: GeodataSectionProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [cron, setCron] = useState(DEFAULT_CRON);
const [outbound, setOutbound] = useState<string | undefined>(undefined);
const [rows, setRows] = useState<GeodataAssetRow[]>([]);
const [outboundTags, setOutboundTags] = useState<string[]>([]);
const [template, setTemplate] = useState<Record<string, unknown> | null>(null);
const outboundTestUrlRef = useRef('');
const load = useCallback(async () => {
try {
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success || typeof msg.obj !== 'string') return;
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
const next = (payload.xraySetting || {}) as Record<string, unknown>;
setTemplate(next);
outboundTestUrlRef.current =
typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : '';
const geodata = (next.geodata || {}) as Record<string, unknown>;
const assets = Array.isArray(geodata.assets) ? geodata.assets : [];
setRows(
assets
.filter((a): a is Record<string, unknown> => !!a && typeof a === 'object')
.map((a) => ({ url: String(a.url ?? ''), file: String(a.file ?? '') })),
);
setCron(typeof geodata.cron === 'string' && geodata.cron ? geodata.cron : DEFAULT_CRON);
setOutbound(
typeof geodata.outbound === 'string' && geodata.outbound ? geodata.outbound : undefined,
);
// Download outbound candidates: template outbounds + subscription outbounds.
// Skip blackhole outbounds — routing a download through one just drops it.
const tags = new Set<string>();
const outbounds = Array.isArray(next.outbounds) ? next.outbounds : [];
for (const o of outbounds) {
if (!o || typeof o !== 'object') continue;
const rec = o as Record<string, unknown>;
if (rec.protocol === 'blackhole') continue;
const tag = rec.tag;
if (typeof tag === 'string' && tag) tags.add(tag);
}
const subTags = Array.isArray(payload.subscriptionOutboundTags)
? payload.subscriptionOutboundTags
: [];
for (const tag of subTags) {
if (typeof tag === 'string' && tag) tags.add(tag);
}
setOutboundTags([...tags]);
} finally {
setLoading(false);
}
}, []);
const [wasActive, setWasActive] = useState(false);
if (active !== wasActive) {
setWasActive(active);
if (active) setLoading(true);
}
useEffect(() => {
if (active) void load();
}, [active, load]);
function setRow(index: number, patch: Partial<GeodataAssetRow>) {
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
}
function onUrlBlur(index: number) {
setRows((prev) =>
prev.map((r, i) => (i === index && !r.file ? { ...r, file: fileNameFromUrl(r.url) } : r)),
);
}
function save() {
if (!template) return;
const assets = rows
.map((r) => ({ url: r.url.trim(), file: r.file.trim() }))
.filter((r) => r.url || r.file);
for (const a of assets) {
// Xray's geodata downloader accepts HTTPS URLs only.
if (!/^https:\/\/\S+$/i.test(a.url)) {
messageApi.error(t('pages.index.geodataInvalidUrl'));
return;
}
if (!FILE_NAME_PATTERN.test(a.file)) {
messageApi.error(t('pages.index.geodataInvalidFile'));
return;
}
}
const cronValue = cron.trim();
if (assets.length > 0 && cronValue && cronValue.split(/\s+/).length !== 5) {
messageApi.error(t('pages.index.geodataInvalidCron'));
return;
}
modal.confirm({
title: t('pages.index.geodataConfirmTitle'),
content: t('pages.index.geodataConfirmContent'),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
const next: Record<string, unknown> = { ...template };
if (assets.length === 0) {
delete next.geodata;
} else {
const geodata: Record<string, unknown> = { assets };
if (cronValue) geodata.cron = cronValue;
if (outbound) geodata.outbound = outbound;
next.geodata = geodata;
}
onClose();
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
try {
const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: JSON.stringify(next, null, 2),
outboundTestUrl: outboundTestUrlRef.current,
});
if (msg?.success) {
await HttpUtil.post('/panel/api/server/restartXrayService');
}
} finally {
onBusy({ busy: false });
}
},
});
}
return (
<div>
{modalContextHolder}
{messageContextHolder}
<Spin spinning={loading}>
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
<Form layout="vertical">
<Form.Item label={t('pages.index.geodataCron')} style={{ marginBottom: 8 }}>
<Input
value={cron}
placeholder={DEFAULT_CRON}
onChange={(e) => setCron(e.target.value)}
/>
</Form.Item>
<Form.Item label={t('pages.index.geodataOutbound')} style={{ marginBottom: 8 }}>
<Select
style={{ width: '100%' }}
allowClear
value={outbound}
onChange={(v) => setOutbound(v)}
options={outboundTags.map((tag) => ({ label: tag, value: tag }))}
/>
</Form.Item>
</Form>
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
{rows.length === 0 && (
<Typography.Text type="secondary">{t('pages.index.geodataEmpty')}</Typography.Text>
)}
{rows.map((row, i) => (
<Space.Compact key={i} style={{ width: '100%' }}>
<Input
style={{ width: '60%' }}
placeholder="https://example.com/geosite_custom.dat"
value={row.url}
onChange={(e) => setRow(i, { url: e.target.value })}
onBlur={() => onUrlBlur(i)}
/>
<Input
style={{ width: '40%' }}
placeholder={t('pages.index.geodataFile')}
value={row.file}
onChange={(e) => setRow(i, { file: e.target.value })}
/>
<Button
aria-label={t('delete')}
icon={<DeleteOutlined />}
onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
/>
</Space.Compact>
))}
<div className="actions-row">
<Button
icon={<PlusOutlined />}
onClick={() => setRows((p) => [...p, { url: '', file: '' }])}
>
{t('pages.index.geodataAddFile')}
</Button>
<Button type="primary" onClick={save} disabled={loading || !template}>
{t('pages.index.geodataSaveRestart')}
</Button>
</div>
</Space>
</Spin>
</div>
);
}
-54
View File
@@ -38,53 +38,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.ov-state {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border: 1px solid var(--ov-line);
border-radius: 999px;
font-size: 13px;
color: var(--ant-color-text);
}
.ov-state-dot {
position: relative;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex: none;
}
.ov-state[data-state='running'] .ov-state-dot::after {
content: '';
position: absolute;
inset: -1px;
border-radius: 50%;
border: 1px solid currentColor;
animation: ovPulse 1.6s infinite ease-out;
}
@keyframes ovPulse {
0% {
transform: scale(0.9);
opacity: 0.5;
}
100% {
transform: scale(2.4);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ov-state[data-state='running'] .ov-state-dot::after {
animation: none;
}
}
.ov-state-version,
.ov-panel-version { .ov-panel-version {
padding: 0; padding: 0;
border: 0; border: 0;
@@ -95,8 +48,6 @@
transition: color 0.2s; transition: color 0.2s;
} }
.ov-state-version:hover,
.ov-state-version:focus-visible,
.ov-panel-version:hover, .ov-panel-version:hover,
.ov-panel-version:focus-visible { .ov-panel-version:focus-visible {
color: var(--ant-color-primary); color: var(--ant-color-primary);
@@ -112,11 +63,6 @@
margin-inline-end: 0; margin-inline-end: 0;
} }
.ov-error-detail {
white-space: pre-wrap;
word-break: break-word;
}
.ov-bar-actions { .ov-bar-actions {
margin-inline-start: auto; margin-inline-start: auto;
display: flex; display: flex;
+20 -73
View File
@@ -32,13 +32,7 @@ import { mean, peak, useOverviewHistory } from './useOverviewHistory';
import type { PanelUpdateInfo } from './PanelUpdateModal'; import type { PanelUpdateInfo } from './PanelUpdateModal';
const JsonEditor = lazy(() => import('@/components/form/JsonEditor')); const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal')); const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
const LogModal = lazy(() => import('./LogModal'));
const BackupModal = lazy(() => import('./BackupModal')); const BackupModal = lazy(() => import('./BackupModal'));
const SystemHistoryModal = lazy(() => import('./SystemHistoryModal'));
const XrayMetricsModal = lazy(() => import('./XrayMetricsModal'));
const XrayLogModal = lazy(() => import('./XrayLogModal'));
const AmneziaWGLogModal = lazy(() => import('./AmneziaWGLogModal'));
const VersionModal = lazy(() => import('./VersionModal'));
import './IndexPage.css'; import './IndexPage.css';
export default function IndexPage() { export default function IndexPage() {
@@ -51,7 +45,8 @@ export default function IndexPage() {
setMessageInstance(messageApi); setMessageInstance(messageApi);
}, [messageApi]); }, [messageApi]);
const [accessLogEnable, setAccessLogEnable] = useState(false); const history = useOverviewHistory(status, fetched && !fetchError);
const [devChannelEnable, setDevChannelEnable] = useState(false); const [devChannelEnable, setDevChannelEnable] = useState(false);
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({ const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
currentVersion: '', currentVersion: '',
@@ -62,30 +57,21 @@ export default function IndexPage() {
const basePath = window.X_UI_BASE_PATH || ''; const basePath = window.X_UI_BASE_PATH || '';
const [showIp, setShowIp] = useState(false); const [showIp, setShowIp] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [backupOpen, setBackupOpen] = useState(false); const [backupOpen, setBackupOpen] = useState(false);
const [panelUpdateOpen, setPanelUpdateOpen] = useState(false); const [panelUpdateOpen, setPanelUpdateOpen] = useState(false);
const [sysHistoryOpen, setSysHistoryOpen] = useState(false);
const [xrayMetricsOpen, setXrayMetricsOpen] = useState(false);
const [xrayLogsOpen, setXrayLogsOpen] = useState(false);
const [amneziawgLogsOpen, setAmneziawgLogsOpen] = useState(false);
const [versionOpen, setVersionOpen] = useState(false);
const [configTextOpen, setConfigTextOpen] = useState(false); const [configTextOpen, setConfigTextOpen] = useState(false);
const [configText, setConfigText] = useState(''); const [configText, setConfigText] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingTip, setLoadingTip] = useState(t('loading')); const [loadingTip, setLoadingTip] = useState(t('loading'));
const history = useOverviewHistory(status, fetched && !fetchError);
useEffect(() => { useEffect(() => {
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>( HttpUtil.post<{ devChannelEnable?: boolean }>('/panel/api/setting/defaultSettings').then(
'/panel/api/setting/defaultSettings', (msg) => {
).then((msg) => { if (msg?.success && msg.obj) {
if (msg?.success && msg.obj) { setDevChannelEnable(!!msg.obj.devChannelEnable);
setAccessLogEnable(!!msg.obj.accessLogEnable); }
setDevChannelEnable(!!msg.obj.devChannelEnable); },
} );
});
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => { HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj); if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
}); });
@@ -101,16 +87,6 @@ export default function IndexPage() {
if (tip) setLoadingTip(tip); if (tip) setLoadingTip(tip);
}, []); }, []);
const stopXray = useCallback(async () => {
await HttpUtil.post('/panel/api/server/stopXrayService');
await refresh();
}, [refresh]);
const restartXray = useCallback(async () => {
await HttpUtil.post('/panel/api/server/restartXrayService');
await refresh();
}, [refresh]);
async function handleChannelChange(dev: boolean) { async function handleChannelChange(dev: boolean) {
const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev }); const res = await HttpUtil.post('/panel/api/server/setUpdateChannel', { dev });
if (!res?.success) return; if (!res?.success) return;
@@ -194,23 +170,22 @@ export default function IndexPage() {
) : ( ) : (
<div className="ov-page"> <div className="ov-page">
<OverviewActionBar <OverviewActionBar
status={status}
isMobile={isMobile} isMobile={isMobile}
accessLogEnable={accessLogEnable}
panelVersion={displayVersion} panelVersion={displayVersion}
latestVersion={panelUpdateInfo.latestVersion} latestVersion={
updateAvailable={panelUpdateInfo.updateAvailable} panelUpdateInfo.channel === 'dev'
onStopXray={stopXray} ? panelUpdateInfo.latestVersion
onRestartXray={restartXray} : panelUpdateInfo.displayLatestVersion || panelUpdateInfo.latestVersion
onOpenLogs={() => setLogsOpen(true)} }
onOpenXrayLogs={() => setXrayLogsOpen(true)} updateAvailable={
onOpenAmneziaWGLogs={() => setAmneziawgLogsOpen(true)} panelUpdateInfo.channel === 'dev'
? panelUpdateInfo.updateAvailable
: (panelUpdateInfo.displayUpdateAvailable ??
panelUpdateInfo.updateAvailable)
}
onOpenConfig={openConfig} onOpenConfig={openConfig}
onOpenBackup={() => setBackupOpen(true)} onOpenBackup={() => setBackupOpen(true)}
onOpenSystemHistory={() => setSysHistoryOpen(true)}
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
onOpenPanelUpdate={() => setPanelUpdateOpen(true)} onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
onOpenVersionSwitch={() => setVersionOpen(true)}
/> />
{health && ( {health && (
@@ -307,9 +282,6 @@ export default function IndexPage() {
onBusy={setBusy} onBusy={setBusy}
/> />
</LazyMount> </LazyMount>
<LazyMount when={logsOpen}>
<LogModal open={logsOpen} onClose={() => setLogsOpen(false)} />
</LazyMount>
<LazyMount when={backupOpen}> <LazyMount when={backupOpen}>
<BackupModal <BackupModal
open={backupOpen} open={backupOpen}
@@ -318,31 +290,6 @@ export default function IndexPage() {
onBusy={setBusy} onBusy={setBusy}
/> />
</LazyMount> </LazyMount>
<LazyMount when={sysHistoryOpen}>
<SystemHistoryModal
open={sysHistoryOpen}
status={status}
onClose={() => setSysHistoryOpen(false)}
/>
</LazyMount>
<LazyMount when={xrayMetricsOpen}>
<XrayMetricsModal open={xrayMetricsOpen} onClose={() => setXrayMetricsOpen(false)} />
</LazyMount>
<LazyMount when={xrayLogsOpen}>
<XrayLogModal open={xrayLogsOpen} onClose={() => setXrayLogsOpen(false)} />
</LazyMount>
<LazyMount when={amneziawgLogsOpen}>
<AmneziaWGLogModal open={amneziawgLogsOpen} onClose={() => setAmneziawgLogsOpen(false)} />
</LazyMount>
<LazyMount when={versionOpen}>
<VersionModal
open={versionOpen}
status={status}
onClose={() => setVersionOpen(false)}
onBusy={setBusy}
/>
</LazyMount>
<LazyMount when={configTextOpen}> <LazyMount when={configTextOpen}>
<Modal <Modal
open={configTextOpen} open={configTextOpen}
-172
View File
@@ -1,172 +0,0 @@
.reload-icon {
cursor: pointer;
vertical-align: middle;
margin-left: 10px;
}
.log-toolbar {
flex-wrap: wrap;
row-gap: 8px;
}
.log-toolbar .download-item {
margin-left: auto;
}
.log-container {
--log-stamp: #3c89e8;
--log-debug: #3c89e8;
--log-info: #008771;
--log-notice: #008771;
--log-warning: #f37b24;
--log-error: #e04141;
--log-unknown: #595959;
--log-divider: rgba(128, 128, 128, 0.18);
margin-top: 12px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 60vh;
overflow-y: auto;
border: 1px solid var(--ant-color-border);
border-radius: 6px;
background: var(--ant-color-fill-tertiary);
color: var(--ant-color-text);
}
.log-stamp {
color: var(--log-stamp);
}
.log-level {
margin-left: 4px;
}
.level-debug {
color: var(--log-debug);
}
.level-info {
color: var(--log-info);
}
.level-notice {
color: var(--log-notice);
}
.level-warning {
color: var(--log-warning);
}
.level-error {
color: var(--log-error);
}
.level-unknown {
color: var(--log-unknown);
}
.log-container-mobile {
padding: 8px;
white-space: normal;
max-height: 70vh;
}
.log-empty {
text-align: center;
opacity: 0.5;
padding: 20px 0;
}
.log-line + .log-line {
margin-top: 2px;
}
.log-card {
border-bottom: 1px solid var(--log-divider);
padding: 8px 0;
}
.log-card:last-child {
border-bottom: 0;
}
.log-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.log-time {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.02em;
}
.log-date {
font-size: 10px;
font-weight: 500;
opacity: 0.55;
}
.log-level-badge {
display: inline-block;
font-size: 10px;
line-height: 14px;
padding: 0 6px;
border-radius: 4px;
border: 1px solid currentColor;
letter-spacing: 0.04em;
font-weight: 600;
white-space: nowrap;
background: color-mix(in srgb, currentColor 14%, transparent);
}
.log-body {
font-size: 12px;
word-break: break-word;
}
.log-body-text {
margin-left: 4px;
}
body.dark .log-container {
--log-stamp: #6aa6ee;
--log-debug: #6aa6ee;
--log-info: #4ed3a6;
--log-notice: #4ed3a6;
--log-warning: #ffb872;
--log-error: #ff7575;
--log-unknown: #b5b5b5;
--log-divider: rgba(255, 255, 255, 0.1);
}
html[data-theme='ultra-dark'] .log-container {
--log-stamp: #7fb6f1;
--log-debug: #7fb6f1;
--log-info: #5fd9b0;
--log-notice: #5fd9b0;
--log-warning: #ffcc88;
--log-error: #ff8a8a;
--log-unknown: #c4c4c4;
--log-divider: rgba(255, 255, 255, 0.12);
}
.logmodal-mobile .ant-modal-content {
border-radius: 0;
height: 100vh;
}
.logmodal-mobile .ant-modal-body {
padding: 12px;
}
-202
View File
@@ -1,202 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Form, Modal, Select, Space } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { parseLogLine } from './logParse';
import './LogModal.css';
interface LogModalProps {
open: boolean;
onClose: () => void;
}
const AUTO_UPDATE_INTERVAL = 5000;
export default function LogModal({ open, onClose }: LogModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [rows, setRows] = useState('20');
const [level, setLevel] = useState('info');
const [syslog, setSyslog] = useState(false);
const [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const runRefresh = useCallback(async () => {
try {
const msg = await HttpUtil.post<string[]>(`/panel/api/server/logs/${rows}`, {
level,
syslog,
});
if (msg?.success) {
setLogs(msg.obj || []);
}
await PromiseUtil.sleep(300);
} finally {
setLoading(false);
}
}, [rows, level, syslog]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
});
// The spinner is raised during render so the fetch effect stays side-effect
// free until its response lands.
const refreshKey = open ? `${rows}\u0000${level}\u0000${syslog}` : null;
const [loadingKey, setLoadingKey] = useState<string | null>(null);
if (refreshKey !== loadingKey) {
setLoadingKey(refreshKey);
if (refreshKey) setLoading(true);
}
useEffect(() => {
if (open) void runRefresh();
}, [open, runRefresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
return () => clearInterval(id);
}, [open, autoUpdate]);
const parsedLogs = useMemo(() => logs.map(parseLogLine), [logs]);
function download() {
FileManager.downloadTextFile(logs.join('\n'), 'x-ui.log');
}
const titleNode = (
<>
{t('pages.index.logs')}
<SyncOutlined
spin={loading}
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('refresh')}
onClick={refresh}
onKeyDown={activateOnKey(refresh)}
/>
</>
);
return (
<Modal
open={open}
footer={null}
width={isMobile ? '100vw' : 800}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
className={isMobile ? 'logmodal-mobile' : undefined}
onCancel={onClose}
title={titleNode}
>
<Form layout="inline" className="log-toolbar">
<Form.Item>
<Space.Compact>
<Select
value={rows}
size="small"
style={{ width: 70 }}
onChange={setRows}
options={[
{ value: '20', label: '20' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
{ value: '500', label: '500' },
{ value: '1000', label: '1000' },
]}
/>
<Select
value={level}
size="small"
style={{ minWidth: 95 }}
onChange={setLevel}
options={[
{ value: 'debug', label: t('pages.index.logLevelDebug') },
{ value: 'info', label: t('pages.index.logLevelInfo') },
{ value: 'notice', label: t('pages.index.logLevelNotice') },
{ value: 'warning', label: t('pages.index.logLevelWarning') },
{ value: 'err', label: t('pages.index.logLevelError') },
]}
/>
</Space.Compact>
</Form.Item>
<Form.Item>
<Checkbox checked={syslog} onChange={(e) => setSyslog(e.target.checked)}>
SysLog
</Checkbox>
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
{t('pages.index.autoUpdate')}
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
aria-label={t('download')}
/>
</Form.Item>
</Form>
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
{parsedLogs.length === 0 ? (
<div className="log-empty">No Record...</div>
) : isMobile ? (
parsedLogs.map((log, idx) => (
<div key={idx} className="log-card">
<div className="log-card-head">
{log.stamp && (
<span className="log-time">
{log.time && <span>{log.time}</span>}
{log.time && log.date ? ' ' : ''}
{log.date && <span className="log-date">{log.date}</span>}
</span>
)}
{log.levelText && (
<span className={`log-level-badge ${log.levelClass}`}>{log.levelText}</span>
)}
</div>
{(log.body || log.service) && (
<div className="log-body">
{log.service && <b>{log.service}</b>}
{log.service && log.body ? ' ' : ''}
{log.body && <span className="log-body-text">{log.body}</span>}
</div>
)}
</div>
))
) : (
parsedLogs.map((log, idx) => (
<div key={idx} className="log-line">
{log.stamp && <span className="log-stamp">{log.stamp}</span>}
{log.stamp && log.levelText ? ' ' : ''}
{log.levelText && (
<span className={`log-level ${log.levelClass}`}>{log.levelText}</span>
)}
{(log.body || log.service) && (
<>
{(log.stamp || log.levelText) && <span> - </span>}
{log.service && <b>{log.service}</b>}
{log.service && log.body ? ' ' : ''}
<span>{log.body}</span>
</>
)}
</div>
))
)}
</div>
</Modal>
);
}
+27 -147
View File
@@ -1,41 +1,18 @@
import { Fragment } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Tag, Tooltip } from 'antd'; import { Button, Tag, Tooltip } from 'antd';
import { import { CloudDownloadOutlined, CloudServerOutlined, ControlOutlined } from '@ant-design/icons';
ApiOutlined,
ArrowUpOutlined,
AreaChartOutlined,
BarsOutlined,
CloudDownloadOutlined,
CloudServerOutlined,
ControlOutlined,
FileTextOutlined,
PoweroffOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { formatPanelVersion } from '@/lib/panel-version'; import { formatPanelVersion } from '@/lib/panel-version';
import type { Status } from '@/models/status';
interface OverviewActionBarProps { interface OverviewActionBarProps {
status: Status;
isMobile: boolean; isMobile: boolean;
accessLogEnable: boolean;
panelVersion: string; panelVersion: string;
latestVersion: string; latestVersion: string;
updateAvailable: boolean; updateAvailable: boolean;
onStopXray: () => void;
onRestartXray: () => void;
onOpenLogs: () => void;
onOpenXrayLogs: () => void;
onOpenAmneziaWGLogs: () => void;
onOpenConfig: () => void; onOpenConfig: () => void;
onOpenBackup: () => void; onOpenBackup: () => void;
onOpenSystemHistory: () => void;
onOpenXrayMetrics: () => void;
onOpenPanelUpdate: () => void; onOpenPanelUpdate: () => void;
onOpenVersionSwitch: () => void;
} }
interface BarAction { interface BarAction {
@@ -46,127 +23,35 @@ interface BarAction {
primary?: boolean; primary?: boolean;
} }
const XRAY_STATE_KEYS: Record<string, string> = {
running: 'pages.index.xrayStatusRunning',
stop: 'pages.index.xrayStatusStop',
error: 'pages.index.xrayStatusError',
};
export default function OverviewActionBar({ export default function OverviewActionBar({
status,
isMobile, isMobile,
accessLogEnable,
panelVersion, panelVersion,
latestVersion, latestVersion,
updateAvailable, updateAvailable,
onStopXray,
onRestartXray,
onOpenLogs,
onOpenXrayLogs,
onOpenAmneziaWGLogs,
onOpenConfig, onOpenConfig,
onOpenBackup, onOpenBackup,
onOpenSystemHistory,
onOpenXrayMetrics,
onOpenPanelUpdate, onOpenPanelUpdate,
onOpenVersionSwitch,
}: OverviewActionBarProps) { }: OverviewActionBarProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const stateText = t(XRAY_STATE_KEYS[status.xray.state] ?? 'pages.index.xrayStatusUnknown');
const hasVersion = !!status.xray.version && status.xray.version !== 'Unknown';
const size = isMobile ? ('small' as const) : ('middle' as const); const size = isMobile ? ('small' as const) : ('middle' as const);
const actionGroups: BarAction[][] = [ const actions: BarAction[] = [
[ {
{ key: 'config',
key: 'restart', icon: <ControlOutlined />,
icon: <ReloadOutlined />, text: t('pages.index.config'),
text: t('pages.index.restartXray'), onClick: onOpenConfig,
onClick: onRestartXray, },
primary: true, {
}, key: 'backup',
{ icon: <CloudServerOutlined />,
key: 'stop', text: t('pages.index.backupTitle'),
icon: <PoweroffOutlined />, onClick: onOpenBackup,
text: t('pages.index.stopXray'), },
onClick: onStopXray,
},
],
[
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
...(accessLogEnable
? [
{
key: 'accessLogs',
icon: <FileTextOutlined />,
text: t('pages.index.accessLogs'),
onClick: onOpenXrayLogs,
},
]
: []),
...(status.amneziawg.configured
? [
{
key: 'amneziawgLogs',
icon: <ApiOutlined />,
text: t('pages.index.amneziawgLogs'),
onClick: onOpenAmneziaWGLogs,
},
]
: []),
{
key: 'config',
icon: <ControlOutlined />,
text: t('pages.index.config'),
onClick: onOpenConfig,
},
{
key: 'backup',
icon: <CloudServerOutlined />,
text: t('pages.index.backupTitle'),
onClick: onOpenBackup,
},
],
[
{
key: 'history',
icon: <AreaChartOutlined />,
text: t('pages.index.systemHistoryTitle'),
onClick: onOpenSystemHistory,
},
{
key: 'metrics',
icon: <ArrowUpOutlined />,
text: t('pages.index.xrayMetricsTitle'),
onClick: onOpenXrayMetrics,
},
],
]; ];
const statePill = (
<span className="ov-state" data-state={status.xray.state}>
<span className="ov-state-dot" style={{ color: status.xray.color }} />
<span>{`${t('pages.index.xrayStatus')} · ${stateText}`}</span>
{hasVersion && (
<Tooltip title={t('pages.index.xraySwitch')}>
<button type="button" className="ov-state-version" onClick={onOpenVersionSwitch}>
{`v${status.xray.version}`}
</button>
</Tooltip>
)}
</span>
);
return ( return (
<div className="ov-bar"> <div className="ov-bar">
{status.xray.state === 'error' && status.xray.errorMsg ? (
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
{statePill}
</Tooltip>
) : (
statePill
)}
{updateAvailable ? ( {updateAvailable ? (
<Tag <Tag
className="ov-update-tag" className="ov-update-tag"
@@ -185,24 +70,19 @@ export default function OverviewActionBar({
)} )}
<div className="ov-bar-actions"> <div className="ov-bar-actions">
{actionGroups.map((group, groupIndex) => ( {actions.map((action) => (
<Fragment key={group[0].key}> <Button
{groupIndex > 0 && <span className="ov-bar-sep" />} key={action.key}
{group.map((action) => ( type={action.primary ? undefined : 'text'}
<Button color={action.primary ? 'primary' : undefined}
key={action.key} variant={action.primary ? 'outlined' : undefined}
type={action.primary ? undefined : 'text'} size={size}
color={action.primary ? 'primary' : undefined} icon={action.icon}
variant={action.primary ? 'outlined' : undefined} aria-label={action.text}
size={size} onClick={action.onClick}
icon={action.icon} >
aria-label={action.text} {isMobile ? undefined : action.text}
onClick={action.onClick} </Button>
>
{isMobile ? undefined : action.text}
</Button>
))}
</Fragment>
))} ))}
</div> </div>
</div> </div>
+21 -6
View File
@@ -17,6 +17,11 @@ export interface PanelUpdateInfo {
currentCommit?: string; currentCommit?: string;
latestCommit?: string; latestCommit?: string;
updateAvailable: boolean; updateAvailable: boolean;
// V-series display versions (items 18/20); the stable channel's update
// decision and labels use these when present.
displayCurrentVersion?: string;
displayLatestVersion?: string;
displayUpdateAvailable?: boolean;
} }
interface BusyEvent { interface BusyEvent {
@@ -47,6 +52,13 @@ export default function PanelUpdateModal({
const isDev = info.channel === 'dev'; const isDev = info.channel === 'dev';
// On the stable channel the V-series display comparison decides (item
// 20); the dev channel keeps its commit-based decision.
const masterUpdateAvailable = isDev
? info.updateAvailable
: (info.displayUpdateAvailable ?? info.updateAvailable);
const displayLatest = info.displayLatestVersion || info.latestVersion;
async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> { async function pollUpdateStatus(expectedRunId: string): Promise<UpdateOutcome> {
await PromiseUtil.sleep(5000); await PromiseUtil.sleep(5000);
const deadline = Date.now() + 90_000; const deadline = Date.now() + 90_000;
@@ -85,13 +97,14 @@ export default function PanelUpdateModal({
title: t('pages.index.panelUpdateDialog'), title: t('pages.index.panelUpdateDialog'),
content: t('pages.index.panelUpdateDialogDesc').replace( content: t('pages.index.panelUpdateDialogDesc').replace(
'#version#', '#version#',
info.latestVersion || '', isDev ? info.latestVersion || '' : displayLatest || '',
), ),
okText: t('confirm'), okText: t('confirm'),
cancelText: t('cancel'), cancelText: t('cancel'),
onOk: async () => { onOk: async () => {
const baseTip = t('pages.index.dontRefresh'); const baseTip = t('pages.index.dontRefresh');
const tip = info.latestVersion ? `${baseTip} (${info.latestVersion})` : baseTip; const tipVersion = isDev ? info.latestVersion : displayLatest;
const tip = tipVersion ? `${baseTip} (${tipVersion})` : baseTip;
onClose(); onClose();
onBusy({ busy: true, tip }); onBusy({ busy: true, tip });
const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel'); const result = await HttpUtil.post<{ runId: string }>('/panel/api/server/updatePanel');
@@ -128,7 +141,7 @@ export default function PanelUpdateModal({
<> <>
{contextHolder} {contextHolder}
<Modal open={open} title={t('pages.index.updatePanel')} footer={null} onCancel={onClose}> <Modal open={open} title={t('pages.index.updatePanel')} footer={null} onCancel={onClose}>
{info.updateAvailable && ( {masterUpdateAvailable && (
<Alert <Alert
type="warning" type="warning"
className="mb-12" className="mb-12"
@@ -166,12 +179,14 @@ export default function PanelUpdateModal({
</Tag> </Tag>
)} )}
</div> </div>
{info.updateAvailable ? ( {masterUpdateAvailable ? (
<div className="version-list-item"> <div className="version-list-item">
<span> <span>
{isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')} {isDev ? t('pages.index.latestCommit') : t('pages.index.latestPanelVersion')}
</span> </span>
<Tag color="purple">{(isDev ? info.latestCommit : info.latestVersion) || '-'}</Tag> <Tag color="purple">
{(isDev ? info.latestCommit : formatPanelVersion(displayLatest)) || '-'}
</Tag>
</div> </div>
) : ( ) : (
<div className="version-list-item"> <div className="version-list-item">
@@ -184,7 +199,7 @@ export default function PanelUpdateModal({
<div className="actions-row"> <div className="actions-row">
<Button <Button
type="primary" type="primary"
disabled={!info.updateAvailable} disabled={!masterUpdateAvailable}
onClick={updatePanel} onClick={updatePanel}
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined />}
> >
@@ -1,34 +0,0 @@
.metric-modal-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.bucket-select {
width: 80px;
}
.history-tabs {
margin-bottom: 4px;
}
.history-chart-title {
margin-bottom: 12px;
font-size: 14px;
font-weight: 600;
color: var(--ant-color-text);
}
.cpu-chart-wrap {
margin: 8px 8px 16px;
padding: 16px 18px 18px;
border-radius: 14px;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--ant-color-primary) 6%, transparent),
transparent
);
border: 1px solid var(--ant-color-border-secondary);
box-shadow: 0 2px 12px var(--ant-color-fill-quaternary);
}
@@ -1,406 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, Select, Tabs } from 'antd';
import {
ApiOutlined,
DashboardOutlined,
DatabaseOutlined,
DeploymentUnitOutlined,
GlobalOutlined,
HddOutlined,
LineChartOutlined,
PieChartOutlined,
TeamOutlined,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter } from '@/utils';
import { Sparkline } from '@/components/viz';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import type { Status } from '@/models/status';
import './SystemHistoryModal.css';
interface SystemHistoryModalProps {
open: boolean;
status: Status;
onClose: () => void;
}
interface MetricDef {
key: string;
tab: string;
tabKey?: string;
title: string;
icon: ReactNode;
valueMax: number | null;
unit: string;
stroke: string;
key2?: string;
stroke2?: string;
name1?: string;
name2?: string;
key3?: string;
stroke3?: string;
name3?: string;
}
const METRICS: MetricDef[] = [
{
key: 'cpu',
tab: 'CPU',
tabKey: 'pages.index.cpu',
title: 'pages.index.historyTitleCpu',
icon: <DashboardOutlined />,
valueMax: 100,
unit: '%',
stroke: '',
},
{
key: 'mem',
tab: 'RAM',
tabKey: 'pages.index.memory',
title: 'pages.index.historyTitleMem',
icon: <DatabaseOutlined />,
valueMax: 100,
unit: '%',
stroke: '#7c4dff',
key2: 'swap',
stroke2: '#ffa940',
name1: 'pages.index.memory',
name2: 'pages.index.swap',
},
{
key: 'netUp',
tab: 'Bandwidth',
tabKey: 'pages.index.historyTabBandwidth',
title: 'pages.index.historyTitleNetwork',
icon: <GlobalOutlined />,
valueMax: null,
unit: 'B/s',
stroke: '#1890ff',
key2: 'netDown',
stroke2: '#13c2c2',
name1: 'Up',
name2: 'Down',
},
{
key: 'pktUp',
tab: 'Packets',
tabKey: 'pages.index.historyTabPackets',
title: 'pages.index.historyTitlePackets',
icon: <DeploymentUnitOutlined />,
valueMax: null,
unit: 'pkt/s',
stroke: '#2f54eb',
key2: 'pktDown',
stroke2: '#36cfc9',
name1: 'Up',
name2: 'Down',
},
{
key: 'tcpCount',
tab: 'Connections',
tabKey: 'pages.index.historyTabConnections',
title: 'pages.index.historyTitleConnections',
icon: <ApiOutlined />,
valueMax: null,
unit: '',
stroke: '#597ef7',
key2: 'udpCount',
stroke2: '#73d13d',
name1: 'TCP',
name2: 'UDP',
},
{
key: 'diskRead',
tab: 'Disk I/O',
tabKey: 'pages.index.historyTabDisk',
title: 'pages.index.historyTitleDisk',
icon: <HddOutlined />,
valueMax: null,
unit: 'B/s',
stroke: '#eb2f96',
key2: 'diskWrite',
stroke2: '#722ed1',
name1: 'Read',
name2: 'Write',
},
{
key: 'diskUsage',
tab: 'Disk Usage',
tabKey: 'pages.index.historyTabDiskUsage',
title: 'pages.index.historyTitleDiskUsage',
icon: <PieChartOutlined />,
valueMax: 100,
unit: '%',
stroke: '#13c2c2',
},
{
key: 'online',
tab: 'Online',
tabKey: 'pages.index.historyTabOnline',
title: 'pages.index.historyTitleOnline',
icon: <TeamOutlined />,
valueMax: null,
unit: '',
stroke: '#52c41a',
},
{
key: 'load1',
tab: 'Load',
tabKey: 'pages.index.historyTabLoad',
title: 'pages.index.historyTitleLoad',
icon: <LineChartOutlined />,
valueMax: null,
unit: '',
stroke: '#fa8c16',
key2: 'load5',
stroke2: '#f5222d',
name1: '1m',
name2: '5m',
key3: 'load15',
stroke3: '#a0d911',
name3: '15m',
},
];
function unitFormatter(unit: string, activeKey: string): (v: number) => string {
if (unit === 'B/s') {
return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0)).replace(/\.\d+/, '')}/s`;
}
if (unit === 'pkt/s') {
return (v) => `${Math.round(Math.max(0, Number(v) || 0)).toLocaleString()}/s`;
}
if (unit === '%') {
return (v) => `${Number(v).toFixed(1)}%`;
}
return (v) => {
const n = Number(v) || 0;
if (activeKey === 'online' || activeKey === 'tcpCount' || activeKey === 'udpCount') {
return Math.round(n).toLocaleString();
}
return n.toFixed(2);
};
}
function formatFullTimestamp(unixSec: number): string {
const d = new Date(unixSec * 1000);
const today = new Date();
const sameDay =
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const time = `${hh}:${mm}:${ss}`;
if (sameDay) return time;
const MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
return `${MM}-${DD} ${time}`;
}
interface HistoryChart {
points: number[];
points2: number[];
points3: number[];
labels: string[];
timestamps: number[];
}
const EMPTY_CHART: HistoryChart = {
points: [],
points2: [],
points3: [],
labels: [],
timestamps: [],
};
async function loadBucket(metric: (typeof METRICS)[number], bucket: number): Promise<HistoryChart> {
try {
const msg = await HttpUtil.get(`/panel/api/server/history/${metric.key}/${bucket}`);
if (!msg?.success || !Array.isArray(msg.obj)) return EMPTY_CHART;
const points: number[] = [];
const labels: string[] = [];
const timestamps: number[] = [];
for (const p of msg.obj) {
const d = new Date(p.t * 1000);
const MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
labels.push(
bucket >= 2880
? `${MM}-${DD} ${hh}:${mm}`
: bucket >= 60
? `${hh}:${mm}`
: `${hh}:${mm}:${ss}`,
);
points.push(Number(p.v) || 0);
timestamps.push(Number(p.t) || 0);
}
const fetchAligned = async (key?: string): Promise<number[]> => {
if (!key) return [];
const m = await HttpUtil.get(`/panel/api/server/history/${key}/${bucket}`);
if (!m?.success || !Array.isArray(m.obj)) return [];
const byTs = new Map<number, number>();
for (const p of m.obj) byTs.set(Number(p.t) || 0, Number(p.v) || 0);
return timestamps.map((ts) => byTs.get(ts) ?? 0);
};
return {
labels,
points,
timestamps,
points2: await fetchAligned(metric.key2),
points3: await fetchAligned(metric.key3),
};
} catch (e) {
console.error('Failed to fetch history bucket', e);
return EMPTY_CHART;
}
}
export default function SystemHistoryModal({ open, status, onClose }: SystemHistoryModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [activeKey, setActiveKey] = useState('cpu');
const [bucket, setBucket] = useState(2);
const [{ points, points2, points3, labels, timestamps }, setChart] =
useState<HistoryChart>(EMPTY_CHART);
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
const trName = (n?: string) => (n && n.startsWith('pages.') ? t(n) : n);
const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
const yFormatter = useMemo(
() => unitFormatter(activeMetric?.unit ?? '', activeKey),
[activeMetric, activeKey],
);
const tsLookup = useMemo(() => {
const m = new Map<string, number>();
for (let i = 0; i < labels.length; i++) {
m.set(labels[i], timestamps[i]);
}
return m;
}, [labels, timestamps]);
const tooltipLabelFormatter = useCallback(
(label: string) => {
const ts = tsLookup.get(label);
return ts ? formatFullTimestamp(ts) : label;
},
[tsLookup],
);
const fetchBucket = useCallback(async () => {
if (!activeMetric) return;
const next = await loadBucket(activeMetric, bucket);
setChart(next);
}, [activeMetric, bucket]);
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setActiveKey('cpu');
}
useEffect(() => {
if (!open || !activeMetric) return;
let cancelled = false;
void (async () => {
const next = await loadBucket(activeMetric, bucket);
if (!cancelled) setChart(next);
})();
return () => {
cancelled = true;
};
}, [open, activeMetric, bucket]);
useEffect(() => {
if (!open) return undefined;
const ms = bucket <= 30 ? 2000 : 10000;
const id = window.setInterval(() => fetchBucket(), ms);
return () => window.clearInterval(id);
}, [open, bucket, fetchBucket]);
return (
<Modal
open={open}
footer={null}
width={isMobile ? '95vw' : 900}
onCancel={onClose}
title={
<div className="metric-modal-title">
<span>{t('pages.index.systemHistoryTitle')}</span>
<Select
value={bucket}
size="small"
className="bucket-select"
onChange={setBucket}
options={[
{ value: 2, label: '2m' },
{ value: 60, label: '1h' },
{ value: 180, label: '3h' },
{ value: 360, label: '6h' },
{ value: 720, label: '12h' },
{ value: 1440, label: '24h' },
{ value: 2880, label: '2d' },
{ value: 10080, label: '7d' },
]}
/>
</div>
}
>
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
size="small"
className="history-tabs"
items={METRICS.map((m) => {
const tabLabel = m.tabKey ? t(m.tabKey) : m.tab;
return {
key: m.key,
label: isMobile ? (
<span title={tabLabel} aria-label={tabLabel}>
{m.icon}
</span>
) : (
tabLabel
),
};
})}
/>
<div className="cpu-chart-wrap">
{activeMetric?.title && <div className="history-chart-title">{t(activeMetric.title)}</div>}
<Sparkline
data={points}
data2={activeMetric?.key2 ? points2 : undefined}
data3={activeMetric?.key3 ? points3 : undefined}
stroke2={activeMetric?.stroke2}
stroke3={activeMetric?.stroke3}
name1={trName(activeMetric?.name1)}
name2={trName(activeMetric?.name2)}
name3={trName(activeMetric?.name3)}
labels={labels}
height={260}
stroke={strokeColor}
strokeWidth={2.2}
showGrid
showAxes
tickCountX={5}
maxPoints={points.length || 1}
fillOpacity={0.18}
markerRadius={3.2}
showTooltip
valueMin={0}
valueMax={activeMetric?.valueMax ?? null}
yFormatter={yFormatter}
tooltipLabelFormatter={tooltipLabelFormatter}
extrema={{ show: !activeMetric?.key2, formatter: yFormatter }}
/>
</div>
</Modal>
);
}
+5 -12
View File
@@ -29,18 +29,11 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
<ClockCircleOutlined /> <ClockCircleOutlined />
{t('pages.index.uptime')} {t('pages.index.uptime')}
</div> </div>
<div className="ov-strip-split"> {/* The lite master runs no local xray core, so the uptime card
<div> shows the OS uptime only (the Xray half always read 0s). */}
<div className="ov-strip-sub">Xray</div> <div>
<div className="ov-strip-value"> <div className="ov-strip-sub">OS</div>
{TimeFormatter.formatSecond(status.appStats.uptime)} <div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
</div>
</div>
<span className="ov-strip-split-sep" />
<div>
<div className="ov-strip-sub">OS</div>
<div className="ov-strip-value">{TimeFormatter.formatSecond(status.uptime)}</div>
</div>
</div> </div>
</div> </div>
-30
View File
@@ -1,30 +0,0 @@
.version-list {
width: 100%;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
overflow: hidden;
}
.version-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.version-list-item:last-child {
border-bottom: 0;
}
.reload-icon {
cursor: pointer;
font-size: 16px;
margin-right: 8px;
}
.actions-row {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
-179
View File
@@ -1,179 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Modal, Radio, Spin, Tag, Tooltip } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import type { Status } from '@/models/status';
import GeodataSection from './GeodataSection';
import './VersionModal.css';
interface BusyEvent {
busy: boolean;
tip?: string;
}
interface VersionModalProps {
open: boolean;
status: Status;
onClose: () => void;
onBusy: (e: BusyEvent) => void;
}
const GEOFILES = [
'geosite.dat',
'geoip.dat',
'geosite_IR.dat',
'geoip_IR.dat',
'geosite_RU.dat',
'geoip_RU.dat',
];
export default function VersionModal({ open, status, onClose, onBusy }: VersionModalProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [activeKey, setActiveKey] = useState<string | string[]>('1');
const [versions, setVersions] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const fetchVersions = useCallback(async () => {
try {
const msg = await HttpUtil.get<string[]>('/panel/api/server/getXrayVersion');
if (msg?.success) setVersions(msg.obj || []);
} finally {
setLoading(false);
}
}, []);
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setLoading(true);
}
useEffect(() => {
if (open) void fetchVersions();
}, [open, fetchVersions]);
function switchXrayVersion(version: string) {
modal.confirm({
title: t('pages.index.xraySwitchVersionDialog'),
content: t('pages.index.xraySwitchVersionDialogDesc').replace('#version#', version),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
onClose();
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
try {
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
} finally {
onBusy({ busy: false });
}
},
});
}
function updateGeofile(fileName: string) {
const isSingle = !!fileName;
modal.confirm({
title: t('pages.index.geofileUpdateDialog'),
content: isSingle
? t('pages.index.geofileUpdateDialogDesc').replace('#filename#', fileName)
: t('pages.index.geofilesUpdateDialogDesc'),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
onClose();
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
const url = isSingle
? `/panel/api/server/updateGeofile/${fileName}`
: '/panel/api/server/updateGeofile';
try {
await HttpUtil.post(url);
} finally {
onBusy({ busy: false });
}
},
});
}
const activeKeyStr = Array.isArray(activeKey) ? activeKey[0] : activeKey;
return (
<Modal open={open} title={t('pages.index.xrayUpdates')} footer={null} onCancel={onClose}>
{modalContextHolder}
<Spin spinning={loading}>
<Collapse
accordion
activeKey={activeKey}
onChange={setActiveKey}
items={[
{
key: '1',
label: 'Xray',
children: (
<>
<Alert
type="warning"
className="mb-12"
title={t('pages.index.xraySwitchClickDesk')}
showIcon
/>
<div className="version-list">
{versions.map((version, index) => (
<div key={version} className="version-list-item">
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{version}</Tag>
<Radio
checked={version === `v${status?.xray?.version}`}
onClick={() => switchXrayVersion(version)}
/>
</div>
))}
</div>
</>
),
},
{
key: '2',
label: 'Geofiles',
children: (
<>
<div className="version-list">
{GEOFILES.map((file, index) => (
<div key={file} className="version-list-item">
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{file}</Tag>
<Tooltip title={t('update')}>
<ReloadOutlined
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('update')}
onClick={() => updateGeofile(file)}
onKeyDown={activateOnKey(() => updateGeofile(file))}
/>
</Tooltip>
</div>
))}
</div>
<div className="actions-row">
<Button onClick={() => updateGeofile('')}>
{t('pages.index.geofilesUpdateAll')}
</Button>
</div>
</>
),
},
{
key: '3',
label: t('pages.index.geodataTitle'),
children: (
<GeodataSection active={activeKeyStr === '3'} onBusy={onBusy} onClose={onClose} />
),
},
]}
/>
</Spin>
</Modal>
);
}
-151
View File
@@ -1,151 +0,0 @@
.log-toolbar {
flex-wrap: wrap;
row-gap: 8px;
}
.log-toolbar .filter-item {
flex: 1 1 160px;
}
.log-toolbar .download-item {
margin-left: auto;
}
.log-container {
--log-blocked: #e04141;
--log-proxy: #3c89e8;
--log-divider: rgba(128, 128, 128, 0.18);
margin-top: 12px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
max-height: 60vh;
overflow: auto;
border: 1px solid var(--ant-color-border);
border-radius: 6px;
background: var(--ant-color-fill-tertiary);
color: var(--ant-color-text);
}
.log-container-mobile {
padding: 8px;
font-size: 12px;
max-height: 70vh;
}
.log-empty {
text-align: center;
opacity: 0.5;
padding: 20px 0;
}
.log-card {
border-bottom: 1px solid var(--log-divider);
padding: 8px 0;
}
.log-card:last-child {
border-bottom: 0;
}
.log-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.log-time {
font-weight: 600;
font-size: 12px;
letter-spacing: 0.02em;
}
.log-event-tag {
margin: 0;
font-size: 10px;
line-height: 16px;
padding: 0 6px;
}
.log-route {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: 12px;
margin-bottom: 4px;
}
.log-addr {
word-break: break-all;
}
.log-arrow {
opacity: 0.5;
}
.log-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
font-size: 11px;
opacity: 0.75;
}
.log-meta-pair {
display: inline-flex;
align-items: baseline;
gap: 4px;
word-break: break-all;
}
.log-meta-key {
font-size: 10px;
text-transform: uppercase;
opacity: 0.6;
letter-spacing: 0.04em;
}
body.dark .log-container {
--log-blocked: #ff7575;
--log-proxy: #6aa6ee;
--log-divider: rgba(255, 255, 255, 0.1);
}
html[data-theme='ultra-dark'] .log-container {
--log-blocked: #ff8a8a;
--log-proxy: #7fb6f1;
--log-divider: rgba(255, 255, 255, 0.12);
}
.xraylog-modal-mobile .ant-modal-content {
border-radius: 0;
height: 100vh;
}
.xraylog-modal-mobile .ant-modal-body {
padding: 12px;
}
.xraylog-table {
border-collapse: collapse;
width: 100%;
}
.xraylog-table td,
.xraylog-table th {
padding: 2px 15px;
text-align: left;
}
.xraylog-table .log-row-1 {
color: var(--log-blocked);
}
.xraylog-table .log-row-2 {
color: var(--log-proxy);
}
-295
View File
@@ -1,295 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import './XrayLogModal.css';
interface XrayLogModalProps {
open: boolean;
onClose: () => void;
}
interface XrayLogEntry {
DateTime?: string | number;
FromAddress?: string;
ToAddress?: string;
Inbound?: string;
Outbound?: string;
Email?: string;
Event?: number;
}
// The downloaded log is a data format people grep, so it keeps the stable
// tokens; only what is rendered on screen follows the panel language.
const EVENT_TOKENS: Record<number, string> = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
const EVENT_KEYS: Record<number, string> = {
0: 'pages.index.accessDirect',
1: 'pages.index.accessBlocked',
2: 'pages.index.accessProxy',
};
const EVENT_COLORS: Record<number, string> = { 0: 'green', 1: 'red', 2: 'blue' };
function eventToken(ev?: number): string {
return EVENT_TOKENS[ev ?? -1] ?? String(ev ?? '');
}
function eventLabel(t: TFunction, ev?: number): string {
const key = EVENT_KEYS[ev ?? -1];
return key ? t(key) : String(ev ?? '');
}
function eventColor(ev?: number): string {
return EVENT_COLORS[ev ?? -1] ?? 'default';
}
function shortTime(value?: string | number): string {
if (!value) return '';
const d = new Date(value);
if (isNaN(d.getTime())) return '';
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
const AUTO_UPDATE_INTERVAL = 5000;
export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery();
const [rows, setRows] = useState('20');
const [filter, setFilter] = useState('');
const [showDirect, setShowDirect] = useState(true);
const [showBlocked, setShowBlocked] = useState(true);
const [showProxy, setShowProxy] = useState(true);
const [autoUpdate, setAutoUpdate] = useState(false);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<XrayLogEntry[]>([]);
const orderedLogs = useMemo(() => [...logs].reverse(), [logs]);
const runRefresh = useCallback(async () => {
try {
const msg = await HttpUtil.post<XrayLogEntry[]>(`/panel/api/server/xraylogs/${rows}`, {
filter,
showDirect,
showBlocked,
showProxy,
});
if (msg?.success) setLogs(msg.obj || []);
await PromiseUtil.sleep(300);
} finally {
setLoading(false);
}
}, [rows, filter, showDirect, showBlocked, showProxy]);
const refresh = useCallback(() => {
setLoading(true);
void runRefresh();
}, [runRefresh]);
const refreshRef = useRef(refresh);
useEffect(() => {
refreshRef.current = refresh;
});
// The spinner is raised during render so the fetch effect stays side-effect
// free until its response lands.
const refreshKey = open
? `${rows}\u0000${showDirect}\u0000${showBlocked}\u0000${showProxy}`
: null;
const [loadingKey, setLoadingKey] = useState<string | null>(null);
if (refreshKey !== loadingKey) {
setLoadingKey(refreshKey);
if (refreshKey) setLoading(true);
}
useEffect(() => {
if (open) void runRefresh();
}, [open, rows, showDirect, showBlocked, showProxy, runRefresh]);
useEffect(() => {
if (!open || !autoUpdate) return;
const id = setInterval(() => refreshRef.current(), AUTO_UPDATE_INTERVAL);
return () => clearInterval(id);
}, [open, autoUpdate]);
function fullDate(value?: string | number): string {
return IntlUtil.formatDate(value, datepicker);
}
function download() {
if (!Array.isArray(logs) || logs.length === 0) {
FileManager.downloadTextFile('', 'x-ui.log');
return;
}
const lines = logs
.map((l) => {
try {
const dt = l.DateTime ? new Date(l.DateTime) : null;
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
const eventText = eventToken(l.Event);
const emailPart = l.Email ? ` Email=${l.Email}` : '';
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
} catch {
return JSON.stringify(l);
}
})
.join('\n');
FileManager.downloadTextFile(lines, 'x-ui.log');
}
return (
<Modal
open={open}
footer={null}
width={isMobile ? '100vw' : '80vw'}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
className={isMobile ? 'xraylog-modal-mobile' : undefined}
onCancel={onClose}
title={
<>
{t('pages.index.accessLogs')}
<SyncOutlined
spin={loading}
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('refresh')}
onClick={refresh}
onKeyDown={activateOnKey(refresh)}
/>
</>
}
>
<Form layout="inline" className="log-toolbar">
<Form.Item>
<Select
value={rows}
size="small"
style={{ width: 70 }}
onChange={setRows}
options={[
{ value: '20', label: '20' },
{ value: '50', label: '50' },
{ value: '100', label: '100' },
{ value: '500', label: '500' },
{ value: '1000', label: '1000' },
]}
/>
</Form.Item>
<Form.Item label={t('filter')} className="filter-item">
<Input
value={filter}
size="small"
onChange={(e) => setFilter(e.target.value)}
onKeyUp={(e) => {
if (e.key === 'Enter') refresh();
}}
/>
</Form.Item>
<Form.Item>
<Checkbox checked={showDirect} onChange={(e) => setShowDirect(e.target.checked)}>
Direct
</Checkbox>
<Checkbox checked={showBlocked} onChange={(e) => setShowBlocked(e.target.checked)}>
Blocked
</Checkbox>
<Checkbox checked={showProxy} onChange={(e) => setShowProxy(e.target.checked)}>
Proxy
</Checkbox>
<Checkbox checked={autoUpdate} onChange={(e) => setAutoUpdate(e.target.checked)}>
{t('pages.index.autoUpdate')}
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
aria-label={t('download')}
/>
</Form.Item>
</Form>
<div className={`log-container ${isMobile ? 'log-container-mobile' : ''}`}>
{orderedLogs.length === 0 ? (
<div className="log-empty">No Record...</div>
) : isMobile ? (
orderedLogs.map((log, idx) => (
<div key={idx} className="log-card">
<div className="log-card-head">
<span className="log-time" title={fullDate(log.DateTime)}>
{shortTime(log.DateTime)}
</span>
<Tag color={eventColor(log.Event)} className="log-event-tag">
{eventLabel(t, log.Event)}
</Tag>
</div>
<div className="log-route">
<span className="log-addr">{log.FromAddress}</span>
<span className="log-arrow">→</span>
<span className="log-addr">{log.ToAddress}</span>
</div>
<div className="log-meta">
{log.Inbound && (
<span className="log-meta-pair">
<span className="log-meta-key">in</span>
<span className="log-meta-val">{log.Inbound}</span>
</span>
)}
{log.Outbound && (
<span className="log-meta-pair">
<span className="log-meta-key">out</span>
<span className="log-meta-val">{log.Outbound}</span>
</span>
)}
{log.Email && (
<span className="log-meta-pair">
<span className="log-meta-key">email</span>
<span className="log-meta-val">{log.Email}</span>
</span>
)}
</div>
</div>
))
) : (
<table className="xraylog-table">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>To</th>
<th>Inbound</th>
<th>Outbound</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{orderedLogs.map((log, idx) => (
<tr key={idx} className={`log-row-${log.Event}`}>
<td>
<b>{fullDate(log.DateTime)}</b>
</td>
<td>{log.FromAddress}</td>
<td>{log.ToAddress}</td>
<td>{log.Inbound}</td>
<td>{log.Outbound}</td>
<td>{log.Email}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</Modal>
);
}
@@ -1,71 +0,0 @@
.metrics-alert {
margin-bottom: 10px;
}
.obs-pane {
padding: 4px 16px 0;
}
.obs-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.obs-select {
min-width: 240px;
}
.obs-stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.85;
}
.obs-stamp {
opacity: 0.7;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11.5px;
}
.obs-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 18%, transparent);
}
.obs-dot.is-alive {
background: var(--ant-color-success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent);
animation: obs-dot-pulse 2.2s ease-in-out infinite;
}
.obs-dot.is-dead {
background: var(--ant-color-error);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-error) 22%, transparent);
}
@keyframes obs-dot-pulse {
0%,
100% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ant-color-success) 22%, transparent);
}
50% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--ant-color-success) 6%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.obs-dot.is-alive {
animation: none;
}
}
@@ -1,428 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Tabs, Tag } from 'antd';
import {
BlockOutlined,
CloudServerOutlined,
DatabaseOutlined,
DeleteOutlined,
EyeOutlined,
PauseCircleOutlined,
} from '@ant-design/icons';
import { HttpUtil, Msg, SizeFormatter } from '@/utils';
import { Sparkline } from '@/components/viz';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import './XrayMetricsModal.css';
const OBS_KEY = 'xrObs';
interface XrayMetricsModalProps {
open: boolean;
onClose: () => void;
}
interface MetricDef {
key: string;
tab: string;
tabKey: string;
title: string;
icon: ReactNode;
unit: 'B' | 'ns' | 'ms' | '';
stroke: string;
}
interface XrayState {
enabled: boolean;
listen: string;
reason: string;
}
interface ObservatoryTag {
tag: string;
alive: boolean;
delay: number;
lastSeenTime: number;
lastTryTime: number;
}
const METRICS: MetricDef[] = [
{
key: 'xrAlloc',
tab: 'Heap',
tabKey: 'pages.index.xrayTabHeap',
title: 'pages.index.xrayTitleHeap',
icon: <DatabaseOutlined />,
unit: 'B',
stroke: '#7c4dff',
},
{
key: 'xrSys',
tab: 'Sys',
tabKey: 'pages.index.xrayTabSys',
title: 'pages.index.xrayTitleSys',
icon: <CloudServerOutlined />,
unit: 'B',
stroke: '#1890ff',
},
{
key: 'xrHeapObjects',
tab: 'Objects',
tabKey: 'pages.index.xrayTabObjects',
title: 'pages.index.xrayTitleObjects',
icon: <BlockOutlined />,
unit: '',
stroke: '#13c2c2',
},
{
key: 'xrNumGC',
tab: 'GC Count',
tabKey: 'pages.index.xrayTabGcCount',
title: 'pages.index.xrayTitleGcCount',
icon: <DeleteOutlined />,
unit: '',
stroke: '#fa8c16',
},
{
key: 'xrPauseNs',
tab: 'GC Pause',
tabKey: 'pages.index.xrayTabGcPause',
title: 'pages.index.xrayTitleGcPause',
icon: <PauseCircleOutlined />,
unit: 'ns',
stroke: '#f5222d',
},
{
key: OBS_KEY,
tab: 'Observatory',
tabKey: 'pages.index.xrayTabObservatory',
title: 'pages.index.xrayTitleObservatory',
icon: <EyeOutlined />,
unit: 'ms',
stroke: '#52c41a',
},
];
function unitFormatter(unit: string): (v: number) => string {
if (unit === 'B') return (v) => SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0));
if (unit === 'ns') {
return (v) => {
const n = Math.max(0, Number(v) || 0);
if (n >= 1e6) return `${(n / 1e6).toFixed(2)} ms`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} µs`;
return `${n.toFixed(0)} ns`;
};
}
if (unit === 'ms') return (v) => `${Math.round(Number(v) || 0)} ms`;
return (v) => {
const n = Number(v) || 0;
return Math.round(n).toLocaleString();
};
}
function fmtTimestamp(unixSec: number): string {
if (!unixSec) return '—';
const d = new Date(unixSec * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${d.toLocaleDateString()} ${hh}:${mm}:${ss}`;
}
function formatFullTimestamp(unixSec: number): string {
const d = new Date(unixSec * 1000);
const today = new Date();
const sameDay =
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const time = `${hh}:${mm}:${ss}`;
if (sameDay) return time;
const MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
return `${MM}-${DD} ${time}`;
}
interface MetricsChart {
points: number[];
labels: string[];
timestamps: number[];
}
const EMPTY_CHART: MetricsChart = { points: [], labels: [], timestamps: [] };
function toChart(msg: Msg<{ t: number; v: number }[]> | null | undefined, bucket: number) {
if (!msg?.success || !Array.isArray(msg.obj)) return EMPTY_CHART;
const points: number[] = [];
const labels: string[] = [];
const timestamps: number[] = [];
for (const p of msg.obj) {
const d = new Date(p.t * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
labels.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
points.push(Number(p.v) || 0);
timestamps.push(Number(p.t) || 0);
}
return { points, labels, timestamps };
}
async function loadHistory(url: string | null, bucket: number): Promise<MetricsChart> {
if (!url) return EMPTY_CHART;
try {
return toChart(await HttpUtil.get<{ t: number; v: number }[]>(url), bucket);
} catch (e) {
console.error('Failed to fetch xray metrics bucket', e);
return EMPTY_CHART;
}
}
async function loadState(): Promise<XrayState | null> {
try {
const msg = await HttpUtil.get<XrayState>('/panel/api/server/xrayMetricsState');
return msg?.success && msg.obj ? msg.obj : null;
} catch (e) {
console.error('Failed to fetch xray metrics state', e);
return null;
}
}
async function loadObservatory(): Promise<ObservatoryTag[]> {
try {
const msg = await HttpUtil.get<ObservatoryTag[]>('/panel/api/server/xrayObservatory');
return msg?.success && Array.isArray(msg.obj) ? msg.obj : [];
} catch (e) {
console.error('Failed to fetch observatory snapshot', e);
return [];
}
}
export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [activeKey, setActiveKey] = useState('xrAlloc');
const [bucket, setBucket] = useState(2);
const [{ points, labels, timestamps }, setChart] = useState<MetricsChart>(EMPTY_CHART);
const [state, setState] = useState<XrayState>({ enabled: false, listen: '', reason: '' });
const [obsTags, setObsTags] = useState<ObservatoryTag[]>([]);
const [obsActiveTag, setObsActiveTag] = useState('');
const [obsTick, setObsTick] = useState(0);
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
const isObservatory = activeKey === OBS_KEY;
const strokeColor = activeMetric?.stroke || '#008771';
const yFormatter = useMemo(() => unitFormatter(activeMetric?.unit ?? ''), [activeMetric]);
const activeObsTag = obsTags.find((tg) => tg.tag === obsActiveTag) || null;
const tsLookup = useMemo(() => {
const m = new Map<string, number>();
for (let i = 0; i < labels.length; i++) {
m.set(labels[i], timestamps[i]);
}
return m;
}, [labels, timestamps]);
const tooltipLabelFormatter = useCallback(
(label: string) => {
const ts = tsLookup.get(label);
return ts ? formatFullTimestamp(ts) : label;
},
[tsLookup],
);
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setActiveKey('xrAlloc');
}
useEffect(() => {
if (!open) return;
let cancelled = false;
void (async () => {
const next = await loadState();
if (!cancelled && next) setState(next);
})();
return () => {
cancelled = true;
};
}, [open]);
// The observatory snapshot is a live view, so it re-polls; obsTick then pulls
// the chart along with it.
useEffect(() => {
if (!open || !isObservatory) return;
let cancelled = false;
const tick = async () => {
const tags = await loadObservatory();
if (cancelled) return;
setObsTags(tags);
setObsActiveTag((prev) => (tags.find((tg) => tg.tag === prev) ? prev : tags[0]?.tag || ''));
setObsTick((n) => n + 1);
};
void tick();
const id = window.setInterval(() => void tick(), 2000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [open, isObservatory]);
const historyUrl = isObservatory
? obsActiveTag
? `/panel/api/server/xrayObservatoryHistory/${encodeURIComponent(obsActiveTag)}/${bucket}`
: null
: activeMetric
? `/panel/api/server/xrayMetricsHistory/${activeMetric.key}/${bucket}`
: null;
useEffect(() => {
if (!open) return;
let cancelled = false;
void (async () => {
const next = await loadHistory(historyUrl, bucket);
if (!cancelled) setChart(next);
})();
return () => {
cancelled = true;
};
}, [open, historyUrl, bucket, obsTick]);
return (
<Modal
open={open}
footer={null}
width={isMobile ? '95vw' : 900}
onCancel={onClose}
title={
<div className="metric-modal-title">
<span>{t('pages.index.xrayMetricsTitle')}</span>
<Select
value={bucket}
size="small"
className="bucket-select"
onChange={setBucket}
options={[
{ value: 2, label: '2m' },
{ value: 60, label: '1h' },
{ value: 180, label: '3h' },
{ value: 360, label: '6h' },
{ value: 720, label: '12h' },
]}
/>
</div>
}
>
{!state.enabled && (
<Alert
type="warning"
showIcon
className="metrics-alert"
title={t('pages.index.xrayMetricsDisabled')}
description={state.reason || t('pages.index.xrayMetricsHint')}
/>
)}
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
size="small"
className="history-tabs"
items={METRICS.map((m) => {
const tabLabel = m.tabKey ? t(m.tabKey) : m.tab;
return {
key: m.key,
label: isMobile ? (
<span title={tabLabel} aria-label={tabLabel}>
{m.icon}
</span>
) : (
tabLabel
),
};
})}
/>
{isObservatory && (
<div className="obs-pane">
{state.enabled && obsTags.length === 0 ? (
<Alert
type="info"
showIcon
className="metrics-alert"
title={t('pages.index.xrayObservatoryEmpty')}
description={t('pages.index.xrayObservatoryHint')}
/>
) : (
<div className="obs-controls">
<Select
value={obsActiveTag}
size="small"
className="obs-select"
placeholder={t('pages.index.xrayObservatoryTagPlaceholder')}
onChange={setObsActiveTag}
options={obsTags.map((tg) => ({
value: tg.tag,
label: (
<>
<span className={`obs-dot ${tg.alive ? 'is-alive' : 'is-dead'}`} />
{tg.tag}
</>
),
}))}
/>
{activeObsTag && (
<div className="obs-stats">
<Tag color={activeObsTag.alive ? 'green' : 'red'}>
{activeObsTag.alive
? t('pages.index.xrayObservatoryAlive')
: t('pages.index.xrayObservatoryDead')}
</Tag>
<Tag color="blue">{activeObsTag.delay} ms</Tag>
<span className="obs-stamp">
{t('pages.index.xrayObservatoryLastSeen')}:{' '}
{fmtTimestamp(activeObsTag.lastSeenTime)}
</span>
<span className="obs-stamp">
{t('pages.index.xrayObservatoryLastTry')}:{' '}
{fmtTimestamp(activeObsTag.lastTryTime)}
</span>
</div>
)}
</div>
)}
</div>
)}
<div className="cpu-chart-wrap">
{activeMetric?.title && <div className="history-chart-title">{t(activeMetric.title)}</div>}
<Sparkline
data={points}
labels={labels}
height={260}
stroke={strokeColor}
strokeWidth={2.2}
showGrid
showAxes
tickCountX={5}
maxPoints={points.length || 1}
fillOpacity={0.18}
markerRadius={3.2}
showTooltip
valueMin={0}
valueMax={null}
yFormatter={yFormatter}
tooltipLabelFormatter={tooltipLabelFormatter}
extrema={{ show: true, formatter: yFormatter }}
/>
</div>
</Modal>
);
}
-119
View File
@@ -1,119 +0,0 @@
// Parser for the panel log viewer. Logs reach the UI in two shapes:
//
// - App log (SysLog off): the in-memory buffer, formatted as
// "2006/01/02 15:04:05 LEVEL - message"
// - SysLog (journalctl -o short): every entry is prefixed with
// "Mon DD HH:MM:SS host ident[pid]: " before the real message, and the
// message itself is one of several shapes depending on which subsystem
// emitted it:
// "INFO - mtproto: ..." go-logging (x-ui + xray)
// "2026/06/08 19:22:22 http: ..." Go std log (net/http, runtime)
// "[Mon Jun 8 23:56:52 UTC 2026] ERROR ..." telego bot
// "Stopping x-ui.service - ..." systemd
//
// parseLogLine normalises all of these into a stamp + level + service + body so
// the viewer renders a readable line instead of a bare timestamp.
export interface ParsedLog {
date: string;
time: string;
stamp: string;
levelText: string;
levelClass: string;
service: string;
body: string;
}
export const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
export const LEVEL_CLASSES = [
'level-debug',
'level-info',
'level-notice',
'level-warning',
'level-error',
];
// "Mon DD HH:MM:SS host ident[pid]: <message>" — captures the journal date,
// time, and the message that follows the syslog identifier.
const SYSLOG_PREFIX = /^([A-Za-z]{3}\s+\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+\S+?:\s+(.*)$/;
// Redundant Go std-log date prefix ("2006/01/02 15:04:05 ") to strip — the
// journal already carries the timestamp.
const GO_LOG_DATE = /^\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/;
// telego's own line prefix: "[Mon Jan _2 15:04:05 MST 2006] LEVEL rest".
const TELEGO = /^\[[^\]]+\]\s+([A-Z]+)\s+(.*)$/;
// App-log format emitted by the in-memory buffer:
// "2006/01/02 15:04:05 LEVEL - message". Only a line matching this exact shape
// carries a structured timestamp/level; anything else (e.g. a plain notice such
// as the Windows "Syslog is not supported" message) is kept whole as the body.
const APP_LOG = /^(\d{4}\/\d{2}\/\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(\S+)\s+-\s+([\s\S]*)$/;
// splitLevelDash pulls a leading "LEVEL - " off a message, returning the level
// and the remainder. Returns null when the message does not start with a level.
function splitLevelDash(message: string): { level: string; rest: string } | null {
const dash = message.indexOf(' - ');
if (dash < 0) return null;
const level = message.slice(0, dash).trim();
if (LEVELS.indexOf(level) < 0) return null;
return { level, rest: message.slice(dash + 3) };
}
export function parseLogLine(line: string): ParsedLog {
const raw = (line || '').trim();
let date = '';
let time = '';
let levelText = '';
let body: string;
const sys = raw.match(SYSLOG_PREFIX);
if (sys) {
date = sys[1];
time = sys[2];
let message = sys[3];
const ld = splitLevelDash(message);
if (ld) {
// go-logging: "LEVEL - message"
levelText = ld.level;
body = ld.rest;
} else {
// Strip the redundant Go std-log date, then try to lift a level out of a
// telego "[timestamp] LEVEL ..." line; otherwise keep the message as-is.
message = message.replace(GO_LOG_DATE, '');
const tg = message.match(TELEGO);
if (tg && LEVELS.indexOf(tg[1]) >= 0) {
levelText = tg[1];
body = tg[2];
} else {
body = message;
}
}
} else {
const app = raw.match(APP_LOG);
if (app) {
// App-log format: "2006/01/02 15:04:05 LEVEL - body"
date = app[1];
time = app[2];
levelText = app[3];
body = app[4];
} else {
// Plain message with no timestamp/level — show it verbatim.
body = raw;
}
}
const li = LEVELS.indexOf(levelText);
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
let service = '';
if (body.startsWith('XRAY:')) {
service = 'XRAY:';
body = body.slice('XRAY:'.length).trimStart();
} else if (body) {
service = 'X-UI:';
}
const stamp = [date, time].filter(Boolean).join(' ');
return { date, time, stamp, levelText, levelClass, service, body };
}
+1 -1
View File
@@ -179,7 +179,7 @@ export default function LoginPage() {
) : ( ) : (
<div className="login-card"> <div className="login-card">
<div className="brand"> <div className="brand">
<span className="brand-name">3X-UI</span> <span className="brand-name">L-UI</span>
<span className="brand-accent" aria-hidden="true" /> <span className="brand-accent" aria-hidden="true" />
</div> </div>
<h2 className="welcome"> <h2 className="welcome">
+346 -124
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Alert, Alert,
@@ -16,10 +16,11 @@ import {
import { FormProvider, useForm, useWatch } from 'react-hook-form'; import { FormProvider, useForm, useWatch } from 'react-hook-form';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { NodeRecord } from '@/api/queries/useNodesQuery';
import type { RemoteInboundOption } from '@/api/queries/useNodeMutations'; import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
import type { Msg } from '@/utils'; import { HttpUtil, type Msg } from '@/utils';
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node'; import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags'; import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import { currentPanelMasterUrl } from './agentMasterUrl';
import './NodeFormModal.css'; import './NodeFormModal.css';
type Mode = 'add' | 'edit'; type Mode = 'add' | 'edit';
@@ -40,6 +41,7 @@ function defaultValues(): NodeFormValues {
id: 0, id: 0,
name: '', name: '',
remark: '', remark: '',
nodeType: 'traditional',
scheme: 'https', scheme: 'https',
address: '', address: '',
port: 2053, port: 2053,
@@ -76,9 +78,25 @@ export default function NodeFormModal({
const [fetchingInbounds, setFetchingInbounds] = useState(false); const [fetchingInbounds, setFetchingInbounds] = useState(false);
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]); const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
const [testResult, setTestResult] = useState<ProbeResult | null>(null); 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 scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify'; const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all'; 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 }); const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector); // Outbounds and balancers share one picker (like the panel-outbound selector);
@@ -129,6 +147,39 @@ export default function NodeFormModal({
setTestResult(null); 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( const title = useMemo(
() => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')), () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
[mode, t], [mode, t],
@@ -138,13 +189,16 @@ export default function NodeFormModal({
function buildPayload(values: NodeFormValues): Partial<NodeRecord> { function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
const token = values.apiToken.trim(); const token = values.apiToken.trim();
const agentMode = values.nodeType === 'agent';
const payload: Partial<NodeRecord> = { const payload: Partial<NodeRecord> = {
id: values.id || 0, id: values.id || 0,
name: values.name.trim(), name: values.name.trim(),
remark: values.remark?.trim() || '', remark: values.remark?.trim() || '',
nodeType: values.nodeType,
scheme: values.scheme, scheme: values.scheme,
address: values.address.trim(), // Agent nodes have no panel address; the backend ignores these.
port: values.port, address: agentMode ? 'agent' : values.address.trim(),
port: agentMode ? 2053 : values.port,
basePath: values.basePath.trim() || '/', basePath: values.basePath.trim() || '/',
enable: values.enable, enable: values.enable,
allowPrivateAddress: values.allowPrivateAddress, allowPrivateAddress: values.allowPrivateAddress,
@@ -217,18 +271,36 @@ export default function NodeFormModal({
setSubmitting(true); setSubmitting(true);
try { try {
const payload = buildPayload(result.data); const payload = buildPayload(result.data);
const test = await testConnection(payload); const agentMode = result.data.nodeType === 'agent';
const probe = test?.success ? test.obj : null; // Agent nodes have no panel to probe; skip the connection test.
if (!probe || probe.status !== 'online') { if (!agentMode) {
setTestResult( const test = await testConnection(payload);
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') }, const probe = test?.success ? test.obj : null;
); if (!probe || probe.status !== 'online') {
return; setTestResult(
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') },
);
return;
}
setTestResult(probe);
} }
setTestResult(probe);
const msg = await save(payload); const msg = await save(payload);
if (msg?.success) { if (msg?.success) {
onOpenChange(false); // Backend returns { node, agentToken, nodeType } for agent nodes.
const obj = msg.obj as unknown as
| { agentToken?: string; node?: { id?: number } }
| undefined;
if (agentMode && mode === 'add' && obj?.agentToken) {
setAgentToken(obj.agentToken);
setAgentNodeName(result.data.name.trim());
// Default the master URL to the address this panel is being
// viewed through — origin AND base path. Dropping the base
// path made the agent's registration 404. User can edit it.
setAgentMasterUrl(currentPanelMasterUrl());
// Keep the modal open state; the token dialog shows on top.
} else {
onOpenChange(false);
}
} }
} finally { } finally {
setSubmitting(false); setSubmitting(false);
@@ -239,6 +311,13 @@ export default function NodeFormModal({
if (!submitting) onOpenChange(false); 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 ( return (
<> <>
{messageContextHolder} {messageContextHolder}
@@ -255,6 +334,27 @@ export default function NodeFormModal({
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<Form layout="vertical"> <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}> <Row gutter={16}>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<FormField <FormField
@@ -272,80 +372,134 @@ export default function NodeFormModal({
</Col> </Col>
</Row> </Row>
<Row gutter={16}> {!isAgent && (
<Col xs={24} md={6}> <Row gutter={16}>
<FormField <Col xs={24} md={6}>
label={t('pages.nodes.scheme')} <FormField
name="scheme" label={t('pages.nodes.scheme')}
onAfterChange={(value) => { name="scheme"
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip'); onAfterChange={(value) => {
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
}}
>
<Select
options={[
{ value: 'https', label: 'https' },
{ value: 'http', label: 'http' },
]}
/>
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField
label={t('pages.nodes.address')}
name="address"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
>
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
</FormField>
</Col>
<Col xs={24} md={6}>
<FormField
label={t('pages.nodes.port')}
name="port"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</FormField>
</Col>
</Row>
)}
{!isAgent && (
<Row gutter={16}>
<Col xs={24} md={12}>
<FormField label={t('pages.nodes.basePath')} name="basePath">
<Input placeholder="/" />
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<Switch />
</FormField>
</Col>
</Row>
)}
{isAgent && (
<Row gutter={16}>
<Col xs={24}>
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
<Switch />
</FormField>
</Col>
</Row>
)}
{mode === 'edit' && isAgent && shownInstall && (
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>{t('pages.nodes.agentInstallCommand')}</div>
<p>{t('pages.nodes.agentMasterUrlHint')}</p>
<Input
value={editMasterUrl}
onChange={(e) => setEditMasterUrl(e.target.value.trim())}
style={{ marginBottom: 12, fontFamily: 'monospace' }}
/>
{!shownInstall.hasToken && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
title={t('pages.nodes.agentTokenNotStored')}
/>
)}
<Input.TextArea
value={editInstallCmd}
readOnly
rows={4}
style={{ fontFamily: 'monospace', marginBottom: 12 }}
/>
<Button
disabled={!editInstallCmd}
onClick={() => {
navigator.clipboard.writeText(editInstallCmd).then(
() => messageApi.success(t('pages.nodes.agentTokenCopied')),
() => messageApi.error(t('pages.nodes.agentTokenCopyFailed')),
);
}} }}
> >
<Select {t('pages.nodes.copyToken')}
options={[ </Button>
{ value: 'https', label: 'https' }, </div>
{ value: 'http', label: 'http' }, )}
]}
/>
</FormField>
</Col>
<Col xs={24} md={12}>
<FormField
label={t('pages.nodes.address')}
name="address"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
>
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
</FormField>
</Col>
<Col xs={24} md={6}>
<FormField
label={t('pages.nodes.port')}
name="port"
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</FormField>
</Col>
</Row>
<Row gutter={16}> {!isAgent && (
<Col xs={24} md={12}> <FormField
<FormField label={t('pages.nodes.basePath')} name="basePath"> label={t('pages.nodes.allowPrivateAddress')}
<Input placeholder="/" /> name="allowPrivateAddress"
</FormField> valueProp="checked"
</Col> tooltip={t('pages.nodes.allowPrivateAddressHint')}
<Col xs={24} md={12}> >
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked"> <Switch />
<Switch /> </FormField>
</FormField> )}
</Col>
</Row>
<FormField {!isAgent && (
label={t('pages.nodes.allowPrivateAddress')} <FormField
name="allowPrivateAddress" label={t('pages.nodes.tlsVerifyMode')}
valueProp="checked" name="tlsVerifyMode"
tooltip={t('pages.nodes.allowPrivateAddressHint')} tooltip={t('pages.nodes.tlsVerifyModeHint')}
> >
<Switch /> <Select
</FormField> disabled={scheme === 'http'}
options={[
<FormField { value: 'verify', label: t('pages.nodes.tlsVerify') },
label={t('pages.nodes.tlsVerifyMode')} { value: 'pin', label: t('pages.nodes.tlsPin') },
name="tlsVerifyMode" { value: 'skip', label: t('pages.nodes.tlsSkip') },
tooltip={t('pages.nodes.tlsVerifyModeHint')} { value: 'mtls', label: t('pages.nodes.tlsMtls') },
> ]}
<Select />
disabled={scheme === 'http'} </FormField>
options={[ )}
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
{ value: 'pin', label: t('pages.nodes.tlsPin') },
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
]}
/>
</FormField>
{tlsVerifyMode === 'skip' && ( {tlsVerifyMode === 'skip' && (
<Alert <Alert
@@ -380,21 +534,23 @@ export default function NodeFormModal({
</FormField> </FormField>
)} )}
<FormField {!isAgent && (
label={t('pages.nodes.apiToken')} <FormField
name="apiToken" label={t('pages.nodes.apiToken')}
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }} name="apiToken"
tooltip={t('pages.nodes.apiTokenHint')} rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined} tooltip={t('pages.nodes.apiTokenHint')}
> extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
<Input.Password >
placeholder={ <Input.Password
editingWithToken placeholder={
? t('pages.nodes.apiTokenKeepHint') editingWithToken
: t('pages.nodes.apiTokenPlaceholder') ? t('pages.nodes.apiTokenKeepHint')
} : t('pages.nodes.apiTokenPlaceholder')
/> }
</FormField> />
</FormField>
)}
<FormField <FormField
label={t('pages.nodes.outboundTag')} label={t('pages.nodes.outboundTag')}
@@ -455,35 +611,101 @@ export default function NodeFormModal({
</FormField> </FormField>
)} )}
<div className="test-row"> {!isAgent && (
<Button type="default" loading={testing} onClick={onTest}> <div className="test-row">
{t('pages.nodes.testConnection')} <Button type="default" loading={testing} onClick={onTest}>
</Button> {t('pages.nodes.testConnection')}
{testResult && ( </Button>
<div className="test-result"> {testResult && (
{testResult.status === 'online' ? ( <div className="test-result">
<Alert {testResult.status === 'online' ? (
type="success" <Alert
showIcon type="success"
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })} showIcon
description={ title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined description={
} testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined
/> }
) : ( />
<Alert ) : (
type="error" <Alert
showIcon type="error"
title={t('pages.nodes.connectionFailed')} showIcon
description={testResult.error} title={t('pages.nodes.connectionFailed')}
/> description={testResult.error}
)} />
</div> )}
)} </div>
</div> )}
</div>
)}
</Form> </Form>
</FormProvider> </FormProvider>
</Modal> </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,6 +127,17 @@
margin: 0; 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 { .card-history {
margin-top: 4px; margin-top: 4px;
padding-top: 8px; padding-top: 8px;
+135 -51
View File
@@ -1,6 +1,18 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Badge, Button, Card, Dropdown, Modal, Space, Switch, Table, Tag, Tooltip } from 'antd'; import {
Badge,
Button,
Card,
Dropdown,
Modal,
Popover,
Space,
Switch,
Table,
Tag,
Tooltip,
} from 'antd';
import type { BadgeProps } from 'antd'; import type { BadgeProps } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
@@ -48,6 +60,45 @@ function isUpdateEligible(n: NodeRecord): boolean {
return !!n.enable && n.status === 'online'; 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 { interface NodeRow extends NodeRecord {
url: string; url: string;
key: string | number; key: string | number;
@@ -239,7 +290,7 @@ export default function NodeList({
{ {
title: t('pages.nodes.actions'), title: t('pages.nodes.actions'),
align: 'center', align: 'center',
width: 190, width: 150,
render: (_value, record) => render: (_value, record) =>
record.transitive ? ( record.transitive ? (
<Tooltip <Tooltip
@@ -264,13 +315,23 @@ export default function NodeList({
/> />
</Tooltip> </Tooltip>
{isUpdateEligible(record) && ( {isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}> <Tooltip
title={t(
record.nodeType === 'agent'
? 'pages.nodes.updateAgent'
: 'pages.nodes.updatePanel',
)}
>
<Button <Button
type="text" type="text"
size="small" size="small"
style={{ fontSize: 16 }} style={{ fontSize: 16 }}
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined />}
aria-label={t('pages.nodes.updatePanel')} aria-label={t(
record.nodeType === 'agent'
? 'pages.nodes.updateAgent'
: 'pages.nodes.updatePanel',
)}
onClick={() => onUpdateNode(record)} onClick={() => onUpdateNode(record)}
/> />
</Tooltip> </Tooltip>
@@ -303,7 +364,7 @@ export default function NodeList({
title: t('pages.nodes.enable'), title: t('pages.nodes.enable'),
dataIndex: 'enable', dataIndex: 'enable',
align: 'center', align: 'center',
width: 80, width: 64,
render: (_value, record) => render: (_value, record) =>
record.transitive ? ( record.transitive ? (
<span style={{ opacity: 0.4 }}>—</span> <span style={{ opacity: 0.4 }}>—</span>
@@ -362,17 +423,38 @@ export default function NodeList({
</span> </span>
), ),
dataIndex: 'url', dataIndex: 'url',
width: 330,
ellipsis: true, ellipsis: true,
render: (_value, record) => ( render: (_value, record) => {
<a if (record.nodeType === 'agent') {
href={record.url} // Agent nodes have no panel to link to: show the host IPs the
target="_blank" // agent reports with its heartbeat as plain text on one line
rel="noopener noreferrer" // (P13, layout per item 21).
className={showAddress ? 'address-visible' : 'address-hidden'} const ips = [record.ipv4, record.ipv6].filter(Boolean) as string[];
> return (
{record.url} <span className={showAddress ? 'address-visible' : 'address-hidden'}>
</a> {ips.length > 0
), ? ips.map((ip, i) => (
<span key={ip}>
{i > 0 && <span style={{ opacity: 0.45 }}> · </span>}
{ip}
</span>
))
: '-'}
</span>
);
}
return (
<a
href={record.url}
target="_blank"
rel="noopener noreferrer"
className={showAddress ? 'address-visible' : 'address-hidden'}
>
{record.url}
</a>
);
},
}, },
{ {
title: t('pages.nodes.status'), title: t('pages.nodes.status'),
@@ -418,7 +500,13 @@ export default function NodeList({
dataIndex: 'panelVersion', dataIndex: 'panelVersion',
align: 'center', align: 'center',
render: (_value, record) => { 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 = const canUpdate =
record.nodeType !== 'agent' &&
isUpdateEligible(record) && isUpdateEligible(record) &&
isPanelUpdateAvailable(latestVersion, record.panelVersion || ''); isPanelUpdateAvailable(latestVersion, record.panelVersion || '');
return ( return (
@@ -451,51 +539,42 @@ export default function NodeList({
{ {
title: t('clients'), title: t('clients'),
align: 'center', align: 'center',
width: 180, width: 150,
render: (_value, record) => ( render: (_value, record) => (
<Space size={2}> <Space size={2}>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}> <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
<TeamOutlined /> {record.clientCount || 0} <TeamOutlined /> {record.clientCount || 0}
</Tag> </Tag>
{record.activeCount ? ( {record.activeCount ? (
<Tooltip title={t('subscription.active')}> <CategoryChip
<Tag title={t('subscription.active')}
color="green" emails={record.activeEmails}
className="client-count-tag" color="green"
style={{ margin: 0, padding: '0 2px' }} count={record.activeCount}
> />
{record.activeCount}
</Tag>
</Tooltip>
) : null} ) : null}
{record.disabledCount ? ( {record.disabledCount ? (
<Tooltip title={t('disabled')}> <CategoryChip
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}> title={t('disabled')}
{record.disabledCount} emails={record.disabledEmails}
</Tag> count={record.disabledCount}
</Tooltip> />
) : null} ) : null}
{record.depletedCount ? ( {record.depletedCount ? (
<Tooltip title={t('depleted')}> <CategoryChip
<Tag title={t('depleted')}
color="red" emails={record.depletedEmails}
className="client-count-tag" color="red"
style={{ margin: 0, padding: '0 2px' }} count={record.depletedCount}
> />
{record.depletedCount}
</Tag>
</Tooltip>
) : null} ) : null}
{record.onlineCount ? ( {record.onlineCount ? (
<Tooltip title={t('online')}> <CategoryChip
<Tag title={t('online')}
color="blue" emails={record.onlineEmails}
className="client-count-tag" color="blue"
style={{ margin: 0, padding: '0 2px' }} count={record.onlineCount}
> />
{record.onlineCount}
</Tag>
</Tooltip>
) : null} ) : null}
</Space> </Space>
), ),
@@ -504,7 +583,7 @@ export default function NodeList({
title: t('pages.nodes.latency'), title: t('pages.nodes.latency'),
dataIndex: 'latencyMs', dataIndex: 'latencyMs',
align: 'center', align: 'center',
width: 100, width: 84,
render: (_value, record) => render: (_value, record) =>
record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-', record.latencyMs && record.latencyMs > 0 ? `${record.latencyMs} ms` : '-',
}, },
@@ -512,7 +591,7 @@ export default function NodeList({
title: t('pages.nodes.lastHeartbeat'), title: t('pages.nodes.lastHeartbeat'),
dataIndex: 'lastHeartbeat', dataIndex: 'lastHeartbeat',
align: 'center', align: 'center',
width: 120, width: 96,
render: (_value, record) => relativeTime(record.lastHeartbeat), render: (_value, record) => relativeTime(record.lastHeartbeat),
}, },
], ],
@@ -629,7 +708,12 @@ export default function NodeList({
key: 'update', key: 'update',
label: ( label: (
<> <>
<CloudDownloadOutlined /> {t('pages.nodes.updatePanel')} <CloudDownloadOutlined />{' '}
{t(
record.nodeType === 'agent'
? 'pages.nodes.updateAgent'
: 'pages.nodes.updatePanel',
)}
</> </>
), ),
onClick: () => onUpdateNode(record), onClick: () => onUpdateNode(record),
+12
View File
@@ -232,6 +232,18 @@ export default function NodesPage() {
const onUpdateNode = useCallback( const onUpdateNode = useCallback(
(node: NodeRecord) => { (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; devRef.current = false;
modal.confirm({ modal.confirm({
title: t('pages.nodes.updateConfirmTitle', { count: 1 }), title: t('pages.nodes.updateConfirmTitle', { count: 1 }),
@@ -0,0 +1,20 @@
// Default master URL offered in the agent install command.
//
// The agent registers against {master_url}/panel/api/agent/register, and
// the panel itself is usually served under a base path (webBasePath, e.g.
// "/rogue"). Prefilling the bare origin — as the form used to — produced
// an install command whose registration 404'd, leaving the node stuck at
// "unknown" on the panel. The address the operator is viewing the panel
// through already contains the base path, so derive the default from it:
// origin + window.X_UI_BASE_PATH (injected by the panel server). The
// operator can still override it (e.g. a public address instead of a LAN
// or tunnel one).
export function computeDefaultMasterUrl(origin: string, basePath: string | undefined): string {
const trimmed = (basePath || '/').replace(/\/+$/, '');
return origin + trimmed;
}
export function currentPanelMasterUrl(): string {
if (typeof window === 'undefined') return '';
return computeDefaultMasterUrl(window.location.origin, window.X_UI_BASE_PATH);
}
-232
View File
@@ -1,232 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
import type { AllSetting } from '@/models/setting';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import SecretInput from './SecretInput';
interface EmailTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
interface SmtpTestResult {
success: boolean;
stage?: string;
msg: string;
}
export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<SmtpTestResult | null>(null);
const stageLabel: Record<string, string> = {
connect: t('pages.settings.smtpStageConnect'),
auth: t('pages.settings.smtpStageAuth'),
send: t('pages.settings.smtpStageSend'),
};
async function handleTestSmtp() {
setTestLoading(true);
setTestResult(null);
try {
const res = (await HttpUtil.post('/panel/api/setting/testSmtp')) as SmtpTestResult;
setTestResult(res);
} catch (e: unknown) {
setTestResult({
success: false,
msg: e instanceof Error ? e.message : t('pages.settings.requestFailed'),
});
} finally {
setTestLoading(false);
}
}
return (
<Tabs
defaultActiveKey="1"
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.smtpSettings'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEnable')}
description={t('pages.settings.smtpEnableDesc')}
>
<Switch
checked={allSetting.smtpEnable}
onChange={(v) => updateSetting({ smtpEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpHost')}
description={t('pages.settings.smtpHostDesc')}
>
<Input
value={allSetting.smtpHost}
placeholder="smtp.gmail.com"
onChange={(e) => updateSetting({ smtpHost: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpPort')}
badge={<DefaultSettingTag settingKey="smtpPort" value={allSetting.smtpPort} />}
description={t('pages.settings.smtpPortDesc')}
>
<InputNumber
value={allSetting.smtpPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ smtpPort: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpUsername')}
description={t('pages.settings.smtpUsernameDesc')}
>
<Input
value={allSetting.smtpUsername}
placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpUsername: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpPassword')}
description={
allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword
? t('pages.settings.smtpPasswordConfigured')
: t('pages.settings.smtpPasswordDesc')
}
>
<SecretInput
value={allSetting.smtpPassword}
configured={allSetting.hasSmtpPassword}
clearArmed={allSetting.clearSmtpPassword}
placeholder={t('pages.settings.smtpPasswordPlaceholder')}
onChange={(v) => updateSetting({ smtpPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpFrom')}
description={t('pages.settings.smtpFromDesc')}
>
<Input
value={allSetting.smtpFrom}
placeholder="user@gmail.com"
onChange={(e) => updateSetting({ smtpFrom: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpFromName')}
description={t('pages.settings.smtpFromNameDesc')}
>
<Input
value={allSetting.smtpFromName}
placeholder="3x-ui"
onChange={(e) => updateSetting({ smtpFromName: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpTo')}
description={t('pages.settings.smtpToDesc')}
>
<Input
value={allSetting.smtpTo}
placeholder="admin@example.com, ops@example.com"
onChange={(e) => updateSetting({ smtpTo: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEncryption')}
description={t('pages.settings.smtpEncryptionDesc')}
>
<Select
value={allSetting.smtpEncryptionType}
onChange={(v) => updateSetting({ smtpEncryptionType: v })}
options={[
{ value: 'none', label: t('pages.settings.smtpEncryptionNone') },
{ value: 'starttls', label: t('pages.settings.smtpEncryptionStartTLS') },
{ value: 'tls', label: t('pages.settings.smtpEncryptionTLS') },
]}
style={{ width: '100%' }}
/>
</SettingListItem>
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button
type="primary"
icon={<SendOutlined />}
loading={testLoading}
onClick={handleTestSmtp}
>
{t('pages.settings.testSmtp')}
</Button>
{testResult && (
<Alert
type={testResult.success ? 'success' : 'error'}
title={
testResult.success ? (
t('pages.settings.' + testResult.msg)
) : (
<span>
<b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b>{' '}
{t('pages.settings.' + testResult.msg)}
</span>
)
}
showIcon
closable={{ onClose: () => setTestResult(null) }}
/>
)}
</Space>
</>
),
},
{
key: '2',
label: catTabLabel(<MailOutlined />, t('pages.settings.emailNotifications'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.smtpEventBusNotify')}
description={t('pages.settings.smtpEventBusNotifyDesc')}
>
<EmailNotifications allSetting={allSetting} updateSetting={updateSetting} />
</SettingListItem>
</>
),
},
]}
/>
);
}
+1 -338
View File
@@ -1,14 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Input, InputNumber, Select, Switch, Tabs } from 'antd'; import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
import { import { ClockCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
ApartmentOutlined,
BellOutlined,
ClockCircleOutlined,
GlobalOutlined,
SafetyCertificateOutlined,
SettingOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import { HttpUtil, LanguageManager } from '@/utils'; import { HttpUtil, LanguageManager } from '@/utils';
import { onNumber } from '@/utils/onNumber'; import { onNumber } from '@/utils/onNumber';
@@ -16,7 +9,6 @@ import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel'; import { catTabLabel } from './catTabLabel';
import { sanitizePath } from './uriPath'; import { sanitizePath } from './uriPath';
import SecretInput from './SecretInput';
interface ApiMsg<T = unknown> { interface ApiMsg<T = unknown> {
success?: boolean; success?: boolean;
@@ -33,39 +25,9 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage()); const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
const [outboundTagList, setOutboundTagList] = useState<string[]>([]); const [outboundTagList, setOutboundTagList] = useState<string[]>([]);
const [balancerTagList, setBalancerTagList] = useState<string[]>([]); const [balancerTagList, setBalancerTagList] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
// /options is the slim picker-shaped endpoint — it skips the heavy
// per-client settings and clientStats payloads that /list ships.
const msg = (await HttpUtil.get('/panel/api/inbounds/options')) as ApiMsg<
{
tag: string;
protocol: string;
port: number;
}[]
>;
if (cancelled) return;
if (msg?.success && Array.isArray(msg.obj)) {
setInboundOptions(
msg.obj.map((ib) => ({
label: `${ib.tag} (${ib.protocol}@${ib.port})`,
value: ib.tag,
})),
);
} else {
setInboundOptions([]);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
@@ -136,20 +98,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
]; ];
}, [outboundTagList, balancerTagList, t]); }, [outboundTagList, balancerTagList, t]);
const ldapInboundTagList = useMemo(() => {
const csv = allSetting.ldapInboundTags || '';
return csv.length
? csv
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
}, [allSetting.ldapInboundTags]);
function setLdapInboundTagList(list: string[]) {
updateSetting({ ldapInboundTags: Array.isArray(list) ? list.join(',') : '' });
}
function onLangChange(value: string) { function onLangChange(value: string) {
setLang(value); setLang(value);
LanguageManager.setLanguage(value); LanguageManager.setLanguage(value);
@@ -327,43 +275,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
</> </>
), ),
}, },
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.expireTimeDiff')}
badge={<DefaultSettingTag settingKey="expireDiff" value={allSetting.expireDiff} />}
description={t('pages.settings.expireTimeDiffDesc')}
>
<InputNumber
value={allSetting.expireDiff}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ expireDiff: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.trafficDiff')}
badge={
<DefaultSettingTag settingKey="trafficDiff" value={allSetting.trafficDiff} />
}
description={t('pages.settings.trafficDiffDesc')}
>
<InputNumber
value={allSetting.trafficDiff}
min={0}
max={100}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ trafficDiff: v }))}
/>
</SettingListItem>
</>
),
},
{ {
key: '3', key: '3',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile), label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
@@ -392,35 +303,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
</> </>
), ),
}, },
{
key: '4',
label: catTabLabel(<GlobalOutlined />, t('pages.settings.externalTraffic'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.externalTrafficInformEnable')}
description={t('pages.settings.externalTrafficInformEnableDesc')}
>
<Switch
checked={allSetting.externalTrafficInformEnable}
onChange={(v) => updateSetting({ externalTrafficInformEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.externalTrafficInformURI')}
description={t('pages.settings.externalTrafficInformURIDesc')}
>
<Input
value={allSetting.externalTrafficInformURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ externalTrafficInformURI: e.target.value })}
/>
</SettingListItem>
</>
),
},
{ {
key: '5', key: '5',
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile), label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile),
@@ -454,225 +336,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
</> </>
), ),
}, },
{
key: '6',
label: catTabLabel(<ApartmentOutlined />, 'LDAP', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.ldap.enable')}>
<Switch
checked={allSetting.ldapEnable}
onChange={(v) => updateSetting({ ldapEnable: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.host')}>
<Input
value={allSetting.ldapHost}
onChange={(e) => updateSetting({ ldapHost: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.port')}
badge={<DefaultSettingTag settingKey="ldapPort" value={allSetting.ldapPort} />}
>
<InputNumber
value={allSetting.ldapPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapPort: v }))}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.useTls')}>
<Switch
checked={allSetting.ldapUseTLS}
onChange={(v) => updateSetting({ ldapUseTLS: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.skipTlsVerify')}
description={t('pages.settings.ldap.skipTlsVerifyDesc')}
>
<Switch
checked={allSetting.ldapInsecureSkipVerify}
disabled={!allSetting.ldapUseTLS}
onChange={(v) => updateSetting({ ldapInsecureSkipVerify: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.bindDn')}>
<Input
value={allSetting.ldapBindDN}
onChange={(e) => updateSetting({ ldapBindDN: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('password')}
description={
allSetting.hasLdapPassword && !allSetting.clearLdapPassword
? t('pages.settings.ldap.passwordConfigured')
: t('pages.settings.ldap.passwordUnconfigured')
}
>
<SecretInput
value={allSetting.ldapPassword}
configured={allSetting.hasLdapPassword}
clearArmed={allSetting.clearLdapPassword}
placeholder={t('pages.settings.ldap.passwordPlaceholder')}
onChange={(v) => updateSetting({ ldapPassword: v })}
onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}>
<Input
value={allSetting.ldapBaseDN}
onChange={(e) => updateSetting({ ldapBaseDN: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.userFilter')}>
<Input
value={allSetting.ldapUserFilter}
onChange={(e) => updateSetting({ ldapUserFilter: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.userAttr')}>
<Input
value={allSetting.ldapUserAttr}
onChange={(e) => updateSetting({ ldapUserAttr: e.target.value })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.vlessField')}>
<Input
value={allSetting.ldapVlessField}
onChange={(e) => updateSetting({ ldapVlessField: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.flagField')}
description={t('pages.settings.ldap.flagFieldDesc')}
>
<Input
value={allSetting.ldapFlagField}
onChange={(e) => updateSetting({ ldapFlagField: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.truthyValues')}
description={t('pages.settings.ldap.truthyValuesDesc')}
>
<Input
value={allSetting.ldapTruthyValues}
onChange={(e) => updateSetting({ ldapTruthyValues: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.invertFlag')}
description={t('pages.settings.ldap.invertFlagDesc')}
>
<Switch
checked={allSetting.ldapInvertFlag}
onChange={(v) => updateSetting({ ldapInvertFlag: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.syncSchedule')}
description={t('pages.settings.ldap.syncScheduleDesc')}
>
<Input
value={allSetting.ldapSyncCron}
onChange={(e) => updateSetting({ ldapSyncCron: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.inboundTags')}
description={t('pages.settings.ldap.inboundTagsDesc')}
>
<>
<Select
mode="multiple"
value={ldapInboundTagList}
onChange={setLdapInboundTagList}
style={{ width: '100%' }}
options={inboundOptions}
/>
{inboundOptions.length === 0 && (
<div className="ldap-no-inbounds">{t('pages.settings.ldap.noInbounds')}</div>
)}
</>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoCreate')}>
<Switch
checked={allSetting.ldapAutoCreate}
onChange={(v) => updateSetting({ ldapAutoCreate: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.ldap.autoDelete')}>
<Switch
checked={allSetting.ldapAutoDelete}
onChange={(v) => updateSetting({ ldapAutoDelete: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultTotalGb')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultTotalGB"
value={allSetting.ldapDefaultTotalGB}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultTotalGB}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultTotalGB: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultExpiryDays')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultExpiryDays"
value={allSetting.ldapDefaultExpiryDays}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultExpiryDays}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultExpiryDays: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.ldap.defaultIpLimit')}
badge={
<DefaultSettingTag
settingKey="ldapDefaultLimitIP"
value={allSetting.ldapDefaultLimitIP}
/>
}
>
<InputNumber
value={allSetting.ldapDefaultLimitIP}
min={0}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ ldapDefaultLimitIP: v }))}
/>
</SettingListItem>
</>
),
},
]} ]}
/> />
); );
+4 -124
View File
@@ -1,13 +1,11 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Empty, Form, Input, Modal, Space, Spin, Switch, Tabs, message } from 'antd'; import { Button, Empty, Form, Input, Modal, Space, Spin, Switch, Tabs, message } from 'antd';
import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons'; import { ApiOutlined, UserOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, IntlUtil, RandomUtil } from '@/utils'; import { ClipboardManager, HttpUtil, IntlUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel'; import { catTabLabel } from './catTabLabel';
import TwoFactorModal from './TwoFactorModal';
import './SecurityTab.css'; import './SecurityTab.css';
interface ApiMsg<T = unknown> { interface ApiMsg<T = unknown> {
@@ -25,45 +23,18 @@ interface ApiTokenRow {
expiresAt: number; expiresAt: number;
} }
interface SecurityTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
saveSetting: (payload: Partial<AllSetting> & Record<string, unknown>) => Promise<unknown>;
}
const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000; const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000;
function apiTokenCreatedAtMilliseconds(createdAt: number): number { function apiTokenCreatedAtMilliseconds(createdAt: number): number {
return createdAt < UNIX_MILLISECONDS_THRESHOLD ? createdAt * 1000 : createdAt; return createdAt < UNIX_MILLISECONDS_THRESHOLD ? createdAt * 1000 : createdAt;
} }
type TfaType = 'set' | 'confirm'; export default function SecurityTab() {
interface TfaState {
open: boolean;
title: string;
description: string;
token: string;
type: TfaType;
onConfirm: (success: boolean, code?: string) => void;
}
const TFA_INITIAL: TfaState = {
open: false,
title: '',
description: '',
token: '',
type: 'set',
onConfirm: () => {},
};
export default function SecurityTab({ allSetting, updateSetting, saveSetting }: SecurityTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [tfa, setTfa] = useState<TfaState>(TFA_INITIAL);
const [user, setUser] = useState({ const [user, setUser] = useState({
oldUsername: '', oldUsername: '',
oldPassword: '', oldPassword: '',
@@ -79,17 +50,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null); const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null);
const openTfa = useCallback((opts: Omit<TfaState, 'open'>) => {
setTfa({ ...opts, open: true });
}, []);
const onTfaConfirm = useCallback(
(success: boolean, code?: string) => {
tfa.onConfirm(success, code);
},
[tfa],
);
function updateUserField<K extends keyof typeof user>(key: K, value: string) { function updateUserField<K extends keyof typeof user>(key: K, value: string) {
setUser((prev) => ({ ...prev, [key]: value })); setUser((prev) => ({ ...prev, [key]: value }));
} }
@@ -115,19 +75,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
); );
function onUpdateUserClick() { function onUpdateUserClick() {
if (allSetting.twoFactorEnable) { sendUpdateUser();
openTfa({
title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
token: '',
type: 'confirm',
onConfirm: (ok: boolean, code?: string) => {
if (ok) sendUpdateUser(code || '');
},
});
} else {
sendUpdateUser();
}
} }
const fetchApiTokens = useCallback(async () => { const fetchApiTokens = useCallback(async () => {
@@ -217,47 +165,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts)); return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts));
} }
function toggleTwoFactor() {
if (!allSetting.twoFactorEnable) {
const newToken = RandomUtil.randomBase32String();
openTfa({
title: t('pages.settings.security.twoFactorModalSetTitle'),
description: '',
token: newToken,
type: 'set',
onConfirm: (ok: boolean) => {
if (ok) {
messageApi.success(t('pages.settings.security.twoFactorModalSetSuccess'));
updateSetting({ twoFactorToken: newToken, twoFactorEnable: true });
} else {
updateSetting({ twoFactorEnable: false });
}
},
});
} else {
openTfa({
title: t('pages.settings.security.twoFactorModalDeleteTitle'),
description: t('pages.settings.security.twoFactorModalRemoveStep'),
token: '',
type: 'confirm',
onConfirm: async (ok: boolean, code?: string) => {
if (!ok) return;
const next = {
...allSetting,
twoFactorEnable: false,
twoFactorToken: '',
twoFactorCode: code || '',
};
const msg = (await saveSetting(next)) as ApiMsg;
if (msg?.success) {
messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
updateSetting({ twoFactorEnable: false, twoFactorToken: '', hasTwoFactorToken: false });
}
},
});
}
}
return ( return (
<> <>
{messageContextHolder} {messageContextHolder}
@@ -307,23 +214,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
</> </>
), ),
}, },
{
key: '2',
label: catTabLabel(
<SafetyOutlined />,
t('pages.settings.security.twoFactor'),
isMobile,
),
children: (
<SettingListItem
paddings="small"
title={t('pages.settings.security.twoFactorEnable')}
description={t('pages.settings.security.twoFactorEnableDesc')}
>
<Switch checked={allSetting.twoFactorEnable} onClick={toggleTwoFactor} />
</SettingListItem>
),
},
{ {
key: '3', key: '3',
label: catTabLabel(<ApiOutlined />, t('pages.nodes.apiToken'), isMobile), label: catTabLabel(<ApiOutlined />, t('pages.nodes.apiToken'), isMobile),
@@ -422,16 +312,6 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
</Button> </Button>
</div> </div>
</Modal> </Modal>
<TwoFactorModal
open={tfa.open}
title={tfa.title}
description={tfa.description}
token={tfa.token}
type={tfa.type}
onConfirm={onTfaConfirm}
onOpenChange={(open) => setTfa((prev) => ({ ...prev, open }))}
/>
</> </>
); );
} }
+5 -23
View File
@@ -25,11 +25,10 @@ import { AllSettingSchema } from '@/schemas/setting';
import AppSidebar from '@/layouts/AppSidebar'; import AppSidebar from '@/layouts/AppSidebar';
import GeneralTab from './GeneralTab'; import GeneralTab from './GeneralTab';
import SecurityTab from './SecurityTab'; import SecurityTab from './SecurityTab';
import TelegramTab from './TelegramTab';
import EmailTab from './EmailTab';
import SubscriptionGeneralTab from './SubscriptionGeneralTab'; import SubscriptionGeneralTab from './SubscriptionGeneralTab';
import SubscriptionFormatsTab from './SubscriptionFormatsTab'; import SubscriptionFormatsTab from './SubscriptionFormatsTab';
import SubscriptionBalancersTab from './SubscriptionBalancersTab'; import SubscriptionBalancersTab from './SubscriptionBalancersTab';
import TelegramTab from './TelegramTab';
import './SettingsPage.css'; import './SettingsPage.css';
interface ApiMsg { interface ApiMsg {
@@ -40,7 +39,6 @@ const tabSlugs = [
'general', 'general',
'security', 'security',
'telegram', 'telegram',
'email',
'subscription', 'subscription',
'subscription-formats', 'subscription-formats',
'subscription-balancers', 'subscription-balancers',
@@ -77,16 +75,8 @@ export default function SettingsPage() {
setMessageInstance(messageApi); setMessageInstance(messageApi);
}, [messageApi]); }, [messageApi]);
const { const { allSetting, updateSetting, fetched, spinning, setSpinning, saveDisabled, saveAll } =
allSetting, useAllSettings();
updateSetting,
fetched,
spinning,
setSpinning,
saveDisabled,
saveAll,
savePayload,
} = useAllSettings();
const [entryHost] = useState(() => window.location.hostname); const [entryHost] = useState(() => window.location.hostname);
const [entryPort] = useState(() => window.location.port); const [entryPort] = useState(() => window.location.port);
@@ -206,17 +196,9 @@ export default function SettingsPage() {
const categoryBody = useMemo(() => { const categoryBody = useMemo(() => {
switch (activeSlug) { switch (activeSlug) {
case 'security': case 'security':
return ( return <SecurityTab />;
<SecurityTab
allSetting={allSetting}
updateSetting={updateSetting}
saveSetting={savePayload}
/>
);
case 'telegram': case 'telegram':
return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />; return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'email':
return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription': case 'subscription':
return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />; return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-formats': case 'subscription-formats':
@@ -226,7 +208,7 @@ export default function SettingsPage() {
default: default:
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />; return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
} }
}, [activeSlug, allSetting, updateSetting, savePayload]); }, [activeSlug, allSetting, updateSetting]);
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
@@ -112,11 +112,12 @@ export default function SubscriptionGeneralTab({
description={t('pages.settings.subPortDesc')} description={t('pages.settings.subPortDesc')}
> >
<InputNumber <InputNumber
value={allSetting.subPort} value={allSetting.subPort || null}
min={1} min={0}
max={65535} max={65535}
placeholder="2096"
style={{ width: '100%' }} style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ subPort: v }))} onChange={onNumber((v) => updateSetting({ subPort: v }), { onClear: 0 })}
/> />
</SettingListItem> </SettingListItem>
<SettingListItem <SettingListItem
@@ -1,17 +0,0 @@
.qr-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.qr-code {
cursor: pointer;
}
.qr-token {
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
word-break: break-all;
text-align: center;
}
@@ -1,161 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
import * as OTPAuth from 'otpauth';
import { ClipboardManager } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { TotpCodeSchema } from '@/schemas/login';
import './TwoFactorModal.css';
type Type = 'set' | 'confirm';
interface TwoFactorModalProps {
open: boolean;
title?: string;
description?: string;
token?: string;
type?: Type;
onConfirm: (success: boolean, code?: string) => void;
onOpenChange: (open: boolean) => void;
}
export default function TwoFactorModal({
open,
title = '',
description = '',
token = '',
type = 'set',
onConfirm,
onOpenChange,
}: TwoFactorModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [enteredCode, setEnteredCode] = useState('');
const totp = useMemo(() => {
if (!open || !token) return null;
return new OTPAuth.TOTP({
issuer: '3x-ui',
label: 'Administrator',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: token,
});
}, [open, token]);
const qrValue = totp ? totp.toString() : '';
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setEnteredCode('');
}
function close(success: boolean, code = '') {
onConfirm(success, code);
onOpenChange(false);
setEnteredCode('');
}
function onOk() {
const codeOk = TotpCodeSchema.safeParse(enteredCode);
if (!codeOk.success) {
messageApi.error(
t(codeOk.error.issues[0]?.message ?? 'pages.settings.security.twoFactorModalError'),
);
return;
}
if (type === 'confirm' && !token) {
close(true, codeOk.data);
return;
}
if (!totp) return;
if (totp.generate() === codeOk.data) {
close(true);
} else {
messageApi.error(t('pages.settings.security.twoFactorModalError'));
}
}
function onCancel() {
close(false);
}
async function copyToken() {
const ok = await ClipboardManager.copyText(token);
if (ok) messageApi.success(t('copied'));
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={title}
closable
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>
{t('cancel')}
</Button>,
<Button
key="ok"
type="primary"
disabled={!TotpCodeSchema.safeParse(enteredCode).success}
onClick={onOk}
>
{t('confirm')}
</Button>,
]}
>
{type === 'set' ? (
<>
<p>{t('pages.settings.security.twoFactorModalSteps')}</p>
<Divider />
<p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
<div
className="qr-wrap"
role="button"
tabIndex={0}
aria-label={t('copy')}
onClick={copyToken}
onKeyDown={activateOnKey(copyToken)}
>
<QRCode
className="qr-code"
value={qrValue}
size={180}
type="svg"
bordered={false}
color="#000000"
bgColor="#ffffff"
errorLevel="L"
title={t('copy')}
/>
<span className="qr-token">{token}</span>
</div>
<Divider />
<p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
<Input
value={enteredCode}
onChange={(e) => setEnteredCode(e.target.value)}
style={{ width: '100%' }}
aria-label={t('twoFactorCode')}
/>
</>
) : (
<>
<p>{description}</p>
<Input
value={enteredCode}
onChange={(e) => setEnteredCode(e.target.value)}
style={{ width: '100%' }}
aria-label={t('twoFactorCode')}
/>
</>
)}
</Modal>
</>
);
}
-13
View File
@@ -1,16 +1,3 @@
.xray-page .restart-icon {
font-size: 16px;
cursor: pointer;
color: var(--ant-color-primary);
}
.xray-page .restart-result {
max-width: 480px;
white-space: pre-wrap;
font-size: 12px;
margin: 0;
}
.xray-page .advanced-meta h4 { .xray-page .advanced-meta h4 {
margin: 0 0 4px; margin: 0 0 4px;
} }
+4 -127
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router'; import { useLocation } from 'react-router';
import { import {
Alert, Alert,
Button, Button,
@@ -10,7 +10,6 @@ import {
FloatButton, FloatButton,
Layout, Layout,
message, message,
Radio,
Result, Result,
Row, Row,
Space, Space,
@@ -22,26 +21,21 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useXraySetting } from '@/hooks/useXraySetting'; import { useXraySetting } from '@/hooks/useXraySetting';
import type { XraySettingsValue } from '@/hooks/useXraySetting'; import type { XraySettingsValue } from '@/hooks/useXraySetting';
import AppSidebar from '@/layouts/AppSidebar'; import AppSidebar from '@/layouts/AppSidebar';
import { JsonEditor } from '@/components/form';
import { setMessageInstance } from '@/utils/messageBus'; import { setMessageInstance } from '@/utils/messageBus';
import { BasicsTab } from './basics'; import { BasicsTab } from './basics';
import { propagateOutboundTagRename } from './basics/helpers'; import { propagateOutboundTagRename } from './basics/helpers';
import { RoutingTab } from './routing'; import { RoutingTab } from './routing';
import { OutboundsTab } from './outbounds'; import { OutboundsTab } from './outbounds';
import { BalancersTab } from './balancers';
import { import {
cleanupOrphanedBalancerLoopbacks, cleanupOrphanedBalancerLoopbacks,
ensureMissingBalancerLoopbacks, ensureMissingBalancerLoopbacks,
detectBalancerCycles, detectBalancerCycles,
} from './balancers/balancer-loopback'; } from './balancers/balancer-loopback';
import { DnsTab } from './dns';
import { WarpModal, NordModal, PiaModal } from './overrides'; import { WarpModal, NordModal, PiaModal } from './overrides';
import './XrayPage.css'; import './XrayPage.css';
const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', 'advanced']; const SECTION_SLUGS = ['basic', 'routing', 'outbound'];
type AdvKey = 'xraySetting' | 'inboundSettings' | 'outboundSettings' | 'routingRuleSettings';
export default function XrayPage() { export default function XrayPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -61,21 +55,13 @@ export default function XrayPage() {
setXraySetting, setXraySetting,
templateSettings, templateSettings,
setTemplateSettings, setTemplateSettings,
outboundTestUrl,
setOutboundTestUrl,
inboundTags, inboundTags,
clientReverseTags, clientReverseTags,
subscriptionOutbounds, subscriptionOutbounds,
subscriptionOutboundTags, subscriptionOutboundTags,
outboundsTraffic, outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
fetchAll, fetchAll,
resetOutboundsTraffic, resetOutboundsTraffic,
testOutbound,
testSubscriptionOutbound,
testAllOutbounds,
saveAll, saveAll,
resetToDefault, resetToDefault,
} = xs; } = xs;
@@ -83,9 +69,7 @@ export default function XrayPage() {
const [warpOpen, setWarpOpen] = useState(false); const [warpOpen, setWarpOpen] = useState(false);
const [nordOpen, setNordOpen] = useState(false); const [nordOpen, setNordOpen] = useState(false);
const [piaOpen, setPiaOpen] = useState(false); const [piaOpen, setPiaOpen] = useState(false);
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const pathSection = const pathSection =
location.pathname === '/outbound' location.pathname === '/outbound'
? 'outbound' ? 'outbound'
@@ -107,16 +91,6 @@ export default function XrayPage() {
[setTemplateSettings], [setTemplateSettings],
); );
async function onTestOutbound(idx: number, mode: string) {
const outbound = templateSettings?.outbounds?.[idx];
if (outbound) await testOutbound(idx, outbound, mode);
}
async function onTestSubscription(outbound: Record<string, unknown>, mode: string) {
const tag = typeof outbound?.tag === 'string' ? outbound.tag : '';
if (tag) await testSubscriptionOutbound(tag, outbound, mode);
}
function onAddOutbound(outbound: Record<string, unknown>) { function onAddOutbound(outbound: Record<string, unknown>) {
mutate((tt) => { mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = []; if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
@@ -157,59 +131,11 @@ export default function XrayPage() {
}); });
} }
const advancedText = useMemo(() => {
if (advSettings === 'xraySetting') return xraySetting;
const tpl = templateSettings;
if (!tpl) return '';
try {
switch (advSettings) {
case 'inboundSettings':
return JSON.stringify(tpl.inbounds || [], null, 2);
case 'outboundSettings':
return JSON.stringify(tpl.outbounds || [], null, 2);
case 'routingRuleSettings':
return JSON.stringify(tpl.routing?.rules || [], null, 2);
default:
return '';
}
} catch {
return '';
}
}, [advSettings, xraySetting, templateSettings]);
function onAdvancedTextChange(next: string) {
if (advSettings === 'xraySetting') {
setXraySetting(next);
return;
}
let parsed;
try {
parsed = JSON.parse(next);
} catch {
return;
}
mutate((tt) => {
switch (advSettings) {
case 'inboundSettings':
tt.inbounds = parsed;
break;
case 'outboundSettings':
tt.outbounds = parsed;
break;
case 'routingRuleSettings':
if (!tt.routing) tt.routing = {};
tt.routing.rules = parsed;
break;
}
});
}
function onSaveAll() { function onSaveAll() {
try { try {
JSON.parse(xraySetting); JSON.parse(xraySetting);
} catch (e) { } catch (e) {
messageApi.error(`Advanced JSON: ${(e as Error).message}`); messageApi.error(`Template JSON: ${(e as Error).message}`);
navigate('/xray#advanced');
return; return;
} }
if (templateSettings) { if (templateSettings) {
@@ -252,71 +178,22 @@ export default function XrayPage() {
templateSettings={templateSettings} templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings} setTemplateSettings={setTemplateSettings}
outboundsTraffic={outboundsTraffic} outboundsTraffic={outboundsTraffic}
outboundTestStates={outboundTestStates}
subscriptionTestStates={subscriptionTestStates}
testingAll={testingAll}
inboundTags={inboundTags} inboundTags={inboundTags}
subscriptionOutbounds={subscriptionOutbounds} subscriptionOutbounds={subscriptionOutbounds}
subscriptionOutboundTags={subscriptionOutboundTags} subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile} isMobile={isMobile}
onResetTraffic={resetOutboundsTraffic} onResetTraffic={resetOutboundsTraffic}
onTest={onTestOutbound}
onTestSubscription={onTestSubscription}
onTestAll={testAllOutbounds}
onShowWarp={() => setWarpOpen(true)} onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)} onShowNord={() => setNordOpen(true)}
onShowPia={() => setPiaOpen(true)} onShowPia={() => setPiaOpen(true)}
onRefreshXrayData={fetchAll} onRefreshXrayData={fetchAll}
/> />
); );
case 'balancer':
return (
<BalancersTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
clientReverseTags={clientReverseTags}
subscriptionOutboundTags={subscriptionOutboundTags}
isMobile={isMobile}
/>
);
case 'dns':
return (
<DnsTab templateSettings={templateSettings} setTemplateSettings={setTemplateSettings} />
);
case 'advanced':
return (
<>
<div className="advanced-meta">
<h4>{t('pages.xray.Template')}</h4>
<p>{t('pages.xray.TemplateDesc')}</p>
</div>
<Radio.Group
value={advSettings}
buttonStyle="solid"
size={isMobile ? 'small' : 'middle'}
style={{ margin: '12px 0' }}
onChange={(e) => setAdvSettings(e.target.value)}
>
<Radio.Button value="xraySetting">{t('pages.xray.completeTemplate')}</Radio.Button>
<Radio.Button value="inboundSettings">{t('pages.xray.Inbounds')}</Radio.Button>
<Radio.Button value="outboundSettings">{t('pages.xray.Outbounds')}</Radio.Button>
<Radio.Button value="routingRuleSettings">{t('pages.xray.Routings')}</Radio.Button>
</Radio.Group>
<JsonEditor
value={advancedText}
onChange={onAdvancedTextChange}
minHeight="420px"
maxHeight="720px"
/>
</>
);
default: default:
return ( return (
<BasicsTab <BasicsTab
templateSettings={templateSettings} templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings} setTemplateSettings={setTemplateSettings}
outboundTestUrl={outboundTestUrl}
onChangeOutboundTestUrl={setOutboundTestUrl}
onResetDefault={resetToDefault} onResetDefault={resetToDefault}
/> />
); );
@@ -1,425 +0,0 @@
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
import type { Path } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
import { FormField } from '@/components/form/rhf';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { BalancerFormSchema, type BalancerFormValues } from '@/schemas/xray';
import {
BalancerStrategyTypeSchema,
type BalancerStrategyType,
type BalancerObject,
} from '@/schemas/routing';
import { isBalancerLoopbackTag } from './balancer-loopback';
export type BalancerFormValue = BalancerFormValues;
interface BalancerFormModalProps {
open: boolean;
balancer: BalancerFormValue | null;
outboundTags: string[];
balancerTags: string[];
balancers: BalancerObject[];
templateSettings: XraySettingsValue | null;
otherTags: string[];
onClose: () => void;
onConfirm: (value: BalancerFormValue) => void;
}
const STRATEGY_LABELS: Record<string, string> = {
random: 'Random',
roundRobin: 'Round robin',
leastLoad: 'Least load',
leastPing: 'Least ping',
};
const STRATEGIES = BalancerStrategyTypeSchema.options.map((value) => ({
value,
label: STRATEGY_LABELS[value] ?? value,
}));
function initialState(balancer: BalancerFormValue | null): BalancerFormValues {
if (!balancer) {
return { tag: '', strategy: 'random', selector: [], fallbackTag: '' };
}
return {
tag: balancer.tag ?? '',
strategy: (balancer.strategy ?? 'random') as BalancerStrategyType,
selector: [...(balancer.selector ?? [])],
fallbackTag: balancer.fallbackTag ?? '',
settings: balancer.settings,
};
}
export default function BalancerFormModal({
open,
balancer,
outboundTags,
balancerTags,
balancers,
templateSettings,
otherTags,
onClose,
onConfirm,
}: BalancerFormModalProps) {
const { t } = useTranslation();
const methods = useForm<BalancerFormValues>({ defaultValues: initialState(balancer) });
const [submitAttempted, setSubmitAttempted] = useState(false);
const isEdit = balancer != null;
const openBalancer = open ? (balancer ?? null) : undefined;
const [syncedBalancer, setSyncedBalancer] = useState<typeof openBalancer>(undefined);
if (openBalancer !== syncedBalancer) {
setSyncedBalancer(openBalancer);
if (open) {
methods.reset(initialState(balancer));
setSubmitAttempted(false);
}
}
const strategy = useWatch({ control: methods.control, name: 'strategy' });
const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? [];
const costs = useWatch({ control: methods.control, name: 'settings.costs' }) ?? [];
const tagValue = useWatch({ control: methods.control, name: 'tag' }) ?? '';
const fallbackTag = useWatch({ control: methods.control, name: 'fallbackTag' }) ?? '';
const currentTag = tagValue.trim();
const availableBalancerTags = useMemo(
() => balancerTags.filter((tg) => tg !== currentTag),
[balancerTags, currentTag],
);
const cycleInfo = useMemo(() => {
const rules = (templateSettings?.routing?.rules || []) as Array<{
inboundTag?: string[];
balancerTag?: string;
}>;
const resolveLoopback = (tag: string): string | null => {
for (const r of rules) {
if (Array.isArray(r.inboundTag) && r.inboundTag.includes(tag) && r.balancerTag) {
return r.balancerTag;
}
}
return null;
};
const fallbackOf: Record<string, string> = {};
for (const b of balancers) {
if (!b.tag || !b.fallbackTag || b.tag === currentTag) continue;
const target = isBalancerLoopbackTag(b.fallbackTag)
? resolveLoopback(b.fallbackTag)
: b.fallbackTag;
if (target) fallbackOf[b.tag] = target;
}
const result: Record<string, string[]> = {};
for (const tg of availableBalancerTags) {
const visited = new Set<string>();
let cursor = tg;
const path = [tg];
while (cursor && !visited.has(cursor)) {
if (cursor === currentTag) {
result[tg] = path;
break;
}
visited.add(cursor);
cursor = fallbackOf[cursor] || '';
if (cursor) path.push(cursor);
}
}
return result;
}, [currentTag, balancers, availableBalancerTags, templateSettings?.routing?.rules]);
const wouldCreateCycle = !!cycleInfo[fallbackTag];
const fallbackOptions = useMemo(() => {
const options: Array<{ value: string; label: ReactNode; disabled?: boolean; title?: string }> =
[{ value: '', label: `(${t('none')})` }];
for (const tg of outboundTags) {
options.push({ value: tg, label: tg });
}
for (const tg of availableBalancerTags) {
const cycle = cycleInfo[tg];
options.push({
value: tg,
disabled: !!cycle,
title: cycle
? t('pages.xray.balancer.cycleTooltip', { path: cycle.join(' → '), start: currentTag })
: undefined,
label: (
<span>
<Tag color="blue" style={{ marginRight: 4 }}>
{t('pages.xray.rules.balancer')}
</Tag>
{tg}
</span>
),
});
}
return options;
}, [outboundTags, availableBalancerTags, cycleInfo, currentTag, t]);
const isFallbackBalancer = useMemo(
() => balancerTags.includes(fallbackTag),
[balancerTags, fallbackTag],
);
function submit() {
const values = methods.getValues();
const parsed = BalancerFormSchema.safeParse(values);
const trimmedTag = (values.tag ?? '').trim();
const duplicateTag = !!trimmedTag && otherTags.includes(trimmedTag);
methods.clearErrors();
if (!parsed.success) {
const seen = new Set<string>();
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? '');
if (key && !seen.has(key)) {
seen.add(key);
methods.setError(key as Path<BalancerFormValues>, { message: issue.message });
}
}
}
if (!parsed.success || duplicateTag || wouldCreateCycle) {
setSubmitAttempted(true);
return;
}
const result: BalancerFormValues = { ...parsed.data };
if (result.strategy !== 'leastLoad') delete result.settings;
onConfirm(result);
}
const title = isEdit
? `${t('edit')} ${t('pages.xray.Balancers')}`
: `+ ${t('pages.xray.Balancers')}`;
const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
okButtonProps={{ disabled: wouldCreateCycle }}
mask={{ closable: false }}
onOk={submit}
onCancel={onClose}
>
<FormProvider {...methods}>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Controller
control={methods.control}
name="tag"
render={({ field, fieldState }) => {
const trimmed = (field.value ?? '').trim();
const duplicate = !!trimmed && otherTags.includes(trimmed);
const errorMessage = fieldState.error?.message
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
: '';
const showDuplicate =
!errorMessage && (submitAttempted || fieldState.isTouched) && duplicate;
return (
<Form.Item
label={t('pages.xray.balancer.tag')}
required
validateStatus={errorMessage ? 'error' : showDuplicate ? 'warning' : ''}
help={
errorMessage || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')
}
hasFeedback
>
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
ref={field.ref}
placeholder={t('pages.xray.balancer.tagPlaceholder')}
/>
</Form.Item>
);
}}
/>
<FormField name="strategy" label={t('pages.xray.balancer.balancerStrategy')}>
<Select options={STRATEGIES} />
</FormField>
<FormField name="selector" label={t('pages.xray.balancer.selector')} required>
<Select
mode="tags"
tokenSeparators={[',']}
options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
/>
</FormField>
<FormField
name="fallbackTag"
label={t('pages.xray.balancer.fallback')}
extra={t('pages.xray.balancer.fallbackBalancerHint')}
transform={{ output: (v) => v ?? '' }}
>
<Select allowClear options={fallbackOptions} />
</FormField>
{isFallbackBalancer && !wouldCreateCycle && (
<Alert
type="info"
showIcon
title={t('pages.xray.balancer.balancerFallbackInfo')}
style={{ marginBottom: 16 }}
/>
)}
{wouldCreateCycle && (
<Alert
type="error"
showIcon
title={t('pages.xray.balancer.balancerFallbackCycle')}
style={{ marginBottom: 16 }}
/>
)}
{strategy === 'leastLoad' && (
<>
<FormField
name={['settings', 'expected']}
label={t('pages.xray.balancer.expected')}
transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
>
<InputNumber
min={0}
placeholder={t('pages.xray.balancer.expectedPlaceholder')}
style={{ width: '100%' }}
/>
</FormField>
<FormField
name={['settings', 'maxRTT']}
label={t('pages.xray.balancer.maxRtt')}
transform={{
input: (v) => v ?? '',
output: (v) => (typeof v === 'string' && v ? v : undefined),
}}
>
<Input placeholder="e.g. 1s" />
</FormField>
<FormField
name={['settings', 'tolerance']}
label={t('pages.xray.balancer.tolerance')}
transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
>
<InputNumber
min={0}
max={1}
step={0.01}
placeholder="0.01 = 1%"
style={{ width: '100%' }}
/>
</FormField>
<Form.Item label={t('pages.xray.balancer.baselines')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('settings.baselines', [...baselines, ''])}
/>
{baselines.map((b, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Input
value={b}
aria-label={t('pages.xray.balancer.baselines')}
placeholder="e.g. 1s"
onChange={(e) =>
methods.setValue(
'settings.baselines',
baselines.map((x, i) => (i === idx ? e.target.value : x)),
)
}
/>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'settings.baselines',
baselines.filter((_, i) => i !== idx),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.balancer.costs')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
methods.setValue('settings.costs', [
...costs,
{ regexp: false, match: '', value: 1 },
])
}
/>
{costs.map((c, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Switch
checked={c.regexp}
aria-label={t('pages.xray.balancer.costRegexp')}
checkedChildren="re"
unCheckedChildren="lit"
onChange={(v) =>
methods.setValue(
'settings.costs',
costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)),
)
}
/>
<Input
value={c.match}
aria-label={t('pages.xray.balancer.costMatch')}
placeholder="tag pattern"
onChange={(e) =>
methods.setValue(
'settings.costs',
costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)),
)
}
/>
<InputNumber
value={c.value}
aria-label={t('pages.xray.balancer.costValue')}
placeholder="weight"
style={{ width: 100 }}
onChange={(v) =>
methods.setValue(
'settings.costs',
costs.map((x, i) =>
i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x,
),
)
}
/>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'settings.costs',
costs.filter((_, i) => i !== idx),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
</>
)}
</Form>
</FormProvider>
</Modal>
);
}
@@ -1,549 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Dropdown,
Empty,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Tooltip,
message,
} from 'antd';
import {
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
SyncOutlined,
DeploymentUnitOutlined,
RadarChartOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import BalancerFormModal from './BalancerFormModal';
import type { BalancerFormValue } from './BalancerFormModal';
import { syncObservatories } from './balancer-helpers';
import {
isBalancerLoopbackTag,
loopbackTagFor,
resolveLoopbackFallback,
ensureBalancerLoopback,
removeBalancerLoopback,
removeBalancerLoopbackIfOrphaned,
propagateBalancerTagRename,
} from './balancer-loopback';
import { planBalancerDeletion, applyBalancerDeletion } from '../reference-cleanup';
import DeletionImpactList from '../DeletionImpactList';
import ObservatorySettingsTab from './ObservatorySettingsTab';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import { HttpUtil } from '@/utils';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import type {
BalancerObject,
BalancerStrategySettings,
BalancerStrategyType,
} from '@/schemas/routing';
// Live state of one balancer inside the running core, as reported by the
// panel's /xray/balancerStatus endpoint (RoutingService.GetBalancerInfo).
interface BalancerLiveStatus {
tag: string;
running: boolean;
override: string;
selected: string[];
}
interface BalancersTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
clientReverseTags: string[];
subscriptionOutboundTags?: string[];
isMobile: boolean;
}
type BalancerRecord = BalancerObject;
interface BalancerRow {
key: number;
tag: string;
strategy: BalancerStrategyType;
selector: string[];
fallbackTag: string;
displayFallbackTag: string;
settings?: BalancerStrategySettings;
}
const STRATEGY_LABELS: Record<string, string> = {
random: 'Random',
roundRobin: 'Round robin',
leastLoad: 'Least load',
leastPing: 'Least ping',
};
export default function BalancersTab({
templateSettings,
setTemplateSettings,
clientReverseTags,
subscriptionOutboundTags,
isMobile,
}: BalancersTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [modalOpen, setModalOpen] = useState(false);
const [editingBalancer, setEditingBalancer] = useState<BalancerFormValue | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const balancerObjects = useMemo(
() => (templateSettings?.routing?.balancers || []) as BalancerObject[],
[templateSettings?.routing?.balancers],
);
const rows: BalancerRow[] = useMemo(() => {
const list = balancerObjects;
return list.map((b, idx) => ({
key: idx,
tag: b.tag || '',
strategy: (b.strategy?.type ?? 'random') as BalancerStrategyType,
selector: b.selector || [],
fallbackTag: b.fallbackTag || '',
displayFallbackTag: resolveLoopbackFallback(templateSettings!, b.fallbackTag || ''),
settings: b.strategy?.settings,
}));
}, [balancerObjects, templateSettings]);
const outboundTags = useMemo(() => {
const tags = new Set<string>();
for (const o of templateSettings?.outbounds || []) {
if (o?.tag && !isBalancerLoopbackTag(o.tag)) tags.add(o.tag);
}
for (const tag of clientReverseTags || []) {
if (tag) tags.add(tag);
}
for (const tag of subscriptionOutboundTags || []) {
if (tag) tags.add(tag);
}
return [...tags];
}, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
const otherTags = useMemo(() => {
if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);
return rows
.filter((b) => b.key !== editingIndex)
.map((b) => b.tag)
.filter(Boolean);
}, [rows, editingIndex]);
const balancerTags = useMemo(() => {
return otherTags.filter((tg) => !isBalancerLoopbackTag(tg));
}, [otherTags]);
const overrideOptions: Array<{ value: string; label: React.ReactNode }> = useMemo(() => {
return outboundTags.map((tag) => ({ value: tag, label: tag }));
}, [outboundTags]);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
const [liveStatus, setLiveStatus] = useState<Record<string, BalancerLiveStatus>>({});
const [liveLoading, setLiveLoading] = useState(false);
const liveTags = useMemo(
() =>
rows
.map((r) => r.tag)
.filter(Boolean)
.join(','),
[rows],
);
const refreshLive = useCallback(async () => {
if (!liveTags) {
setLiveStatus({});
return;
}
setLiveLoading(true);
try {
const msg = await HttpUtil.post(
'/panel/api/xray/balancerStatus',
{ tags: liveTags },
{ silent: true },
);
if (msg?.success && msg.obj && typeof msg.obj === 'object') {
setLiveStatus(msg.obj as Record<string, BalancerLiveStatus>);
}
} finally {
setLiveLoading(false);
}
}, [liveTags]);
useEffect(() => {
let cancelled = false;
void (async () => {
await refreshLive();
if (cancelled) return;
})();
return () => {
cancelled = true;
};
}, [refreshLive]);
async function setOverride(tag: string, target: string) {
const msg = await HttpUtil.post('/panel/api/xray/balancerOverride', { tag, target });
if (msg?.success) await refreshLive();
}
function openAdd() {
setEditingBalancer(null);
setEditingIndex(null);
setModalOpen(true);
}
function openEdit(idx: number) {
const row = rows[idx];
const resolved: BalancerFormValue = {
...row,
fallbackTag: resolveLoopbackFallback(templateSettings!, row.fallbackTag),
};
setEditingBalancer(resolved);
setEditingIndex(idx);
setModalOpen(true);
}
function onConfirm(form: BalancerFormValue) {
mutate((tt) => {
if (!tt.routing) tt.routing = { rules: [], balancers: [] };
if (!Array.isArray(tt.routing.balancers)) tt.routing.balancers = [];
const list = tt.routing.balancers as BalancerRecord[];
const wire: BalancerRecord = {
tag: form.tag,
selector: [...form.selector],
fallbackTag: '',
};
if (form.strategy && form.strategy !== 'random') {
wire.strategy = { type: form.strategy };
if (form.strategy === 'leastLoad' && form.settings) {
wire.strategy.settings = form.settings;
}
}
const isFallbackABalancer = form.fallbackTag && balancerTags.includes(form.fallbackTag);
if (isFallbackABalancer) {
wire.fallbackTag = loopbackTagFor(form.fallbackTag);
} else {
wire.fallbackTag = form.fallbackTag || '';
}
if (editingIndex == null) {
list.push(wire);
if (isFallbackABalancer) {
ensureBalancerLoopback(tt, form.fallbackTag);
}
} else {
const oldTag = list[editingIndex]?.tag;
const oldFallback = list[editingIndex]?.fallbackTag || '';
list[editingIndex] = wire;
if (oldTag && oldTag !== wire.tag) {
const rules = tt.routing.rules || [];
for (const rule of rules) {
if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag;
}
propagateBalancerTagRename(tt, oldTag, wire.tag);
}
const oldTarget = isBalancerLoopbackTag(oldFallback) ? oldFallback.slice(4) : null;
if (oldTarget && oldTarget !== form.fallbackTag) {
removeBalancerLoopbackIfOrphaned(tt, oldTarget);
}
if (isFallbackABalancer) {
ensureBalancerLoopback(tt, form.fallbackTag);
}
}
syncObservatories(tt);
});
setModalOpen(false);
}
function confirmDelete(idx: number) {
const deletedTag = rows[idx]?.tag;
const lbTag = loopbackTagFor(deletedTag);
const dependents = (templateSettings?.routing?.balancers || [])
.filter((b) => b.tag !== deletedTag && b.fallbackTag === lbTag)
.map((b) => b.tag);
if (dependents.length > 0) {
messageApi.error(
t('pages.xray.balancer.balancerDeleteInUse', { names: dependents.join(', ') }),
);
return;
}
const impact = templateSettings
? planBalancerDeletion(templateSettings, idx)
: { rules: [], balancers: [], observatory: false, burst: false };
modal.confirm({
title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
content: <DeletionImpactList impact={impact} />,
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () =>
mutate((tt) => {
const tag = tt.routing?.balancers?.[idx]?.tag ?? '';
removeBalancerLoopback(tt, tag);
applyBalancerDeletion(tt, idx);
}),
});
}
const columns: ColumnsType<BalancerRow> = [
{
title: '#',
key: 'action',
align: 'center',
width: 100,
render: (_v, _record, index) => (
<div className="action-cell">
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button
aria-label={t('edit')}
shape="circle"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(index)}
/>
)}
<Dropdown
trigger={['click']}
menu={{
items: [
...(isMobile
? [
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEdit(index),
},
]
: []),
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => confirmDelete(index),
},
],
}}
>
<Button aria-label={t('more')} shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
),
},
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
{
title: 'Strategy',
key: 'strategy',
align: 'center',
width: 140,
render: (_v, record) => (
<Tag color={record.strategy === 'random' ? 'purple' : 'green'}>
{STRATEGY_LABELS[record.strategy] || record.strategy}
</Tag>
),
},
{
title: 'Selector',
key: 'selector',
align: 'center',
render: (_v, record) =>
(record.selector || []).map((sel) => (
<Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4 }}>
{sel}
</Tag>
)),
},
{
title: 'Fallback',
dataIndex: 'displayFallbackTag',
key: 'displayFallbackTag',
align: 'center',
width: 160,
},
{
title: t('pages.xray.balancerLive'),
key: 'live',
align: 'center',
width: 170,
render: (_v, record) => {
const live = liveStatus[record.tag];
if (!live?.running) {
return (
<Tooltip title={t('pages.xray.balancerNotRunning')}>
<Tag>—</Tag>
</Tooltip>
);
}
const resolve = (tag: string) =>
isBalancerLoopbackTag(tag) ? resolveLoopbackFallback(templateSettings!, tag) : tag;
const picked = live.override
? resolve(live.override)
: live.selected?.[0]
? resolve(live.selected[0])
: record.displayFallbackTag;
const tooltipText = live.override
? resolve(live.override)
: (live.selected || []).map(resolve).join(', ');
return (
<Tooltip title={tooltipText || undefined}>
<Tag color={live.override ? 'orange' : 'blue'}>{picked || '—'}</Tag>
</Tooltip>
);
},
},
{
title: t('pages.xray.balancerOverride'),
key: 'overrideTarget',
align: 'center',
width: 200,
render: (_v, record) => {
const live = liveStatus[record.tag];
const resolvedFB = record.displayFallbackTag;
let options = overrideOptions;
if (resolvedFB && !outboundTags.includes(resolvedFB)) {
options = [
...overrideOptions,
{
value: resolvedFB,
label: (
<span>
<Tag color="blue" style={{ marginRight: 4 }}>
{t('pages.xray.rules.balancer')}
</Tag>
{resolvedFB}
</span>
),
},
];
}
const rawOverride = live?.override || undefined;
const resolvedOverride =
rawOverride && isBalancerLoopbackTag(rawOverride)
? resolveLoopbackFallback(templateSettings!, rawOverride)
: rawOverride;
return (
<Select
size="small"
style={{ width: 170 }}
placeholder={t('pages.xray.balancerOverridePh')}
allowClear
disabled={!live?.running}
value={resolvedOverride}
options={options}
onChange={(v) => setOverride(record.tag, (v as string | undefined) || '')}
/>
);
},
},
];
const balancerSettingsTab = (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
{rows.length === 0 ? (
<Empty description={t('emptyBalancersDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Balancers')}
</Button>
</Empty>
) : (
<>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Balancers')}
</Button>
<Tooltip title={t('pages.xray.balancerLiveRefresh')}>
<Button
aria-label={t('pages.xray.balancerLiveRefresh')}
icon={<SyncOutlined spin={liveLoading} />}
onClick={refreshLive}
/>
</Tooltip>
</Space>
<Table
columns={columns}
dataSource={rows}
rowKey={(r) => r.key}
pagination={false}
size="small"
scroll={{ x: 700 }}
/>
</>
)}
</Space>
);
return (
<>
{modalContextHolder}
{messageContextHolder}
<Tabs
items={[
{
key: 'balancers',
label: catTabLabel(
<DeploymentUnitOutlined />,
t('pages.xray.tabBalancerSettings'),
isMobile,
),
children: balancerSettingsTab,
},
{
key: 'observatory',
label: catTabLabel(<RadarChartOutlined />, t('pages.xray.tabObservatory'), isMobile),
children: (
<ObservatorySettingsTab templateSettings={templateSettings} mutate={mutate} />
),
},
]}
/>
<BalancerFormModal
key={modalOpen ? `${editingIndex ?? 'new'}-${editingBalancer?.tag ?? ''}` : 'closed'}
open={modalOpen}
balancer={editingBalancer}
outboundTags={outboundTags}
balancerTags={balancerTags}
balancers={balancerObjects}
templateSettings={templateSettings}
otherTags={otherTags}
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
</>
);
}
@@ -1,235 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Empty, Input, InputNumber, Select, Space, Switch, Tag } from 'antd';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import {
BurstObservatorySchema,
ObservatoryHttpMethodSchema,
ObservatorySchema,
type BurstObservatoryObject,
type ObservatoryHttpMethod,
type ObservatoryObject,
type PingConfigObject,
} from '@/schemas/observatory';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import { settingsRequireBurstObservatory } from './balancer-helpers';
interface ObservatorySettingsTabProps {
templateSettings: XraySettingsValue | null;
mutate: (mutator: (next: XraySettingsValue) => void) => void;
}
const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({});
const BURST_DEFAULTS = BurstObservatorySchema.parse({});
function asObject(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
}
function SelectorTags({ tags }: { tags: string[] }) {
if (!tags || tags.length === 0) return <Tag>—</Tag>;
return (
<>
{tags.map((sel) => (
<Tag
key={sel}
className="info-large-tag"
style={{ margin: 0, marginRight: 4, marginBottom: 4 }}
>
{sel}
</Tag>
))}
</>
);
}
export default function ObservatorySettingsTab({
templateSettings,
mutate,
}: ObservatorySettingsTabProps) {
const { t } = useTranslation();
const observatory = useMemo<ObservatoryObject | null>(() => {
const raw = templateSettings?.observatory;
if (raw == null) return null;
return { ...OBSERVATORY_DEFAULTS, ...asObject(raw) } as ObservatoryObject;
}, [templateSettings?.observatory]);
const burst = useMemo<BurstObservatoryObject | null>(() => {
const raw = templateSettings?.burstObservatory;
if (raw == null) return null;
const merged = { ...BURST_DEFAULTS, ...asObject(raw) } as BurstObservatoryObject;
merged.pingConfig = {
...BURST_DEFAULTS.pingConfig,
...asObject(merged.pingConfig),
} as PingConfigObject;
return merged;
}, [templateSettings?.burstObservatory]);
const hasObservatory = observatory != null;
const hasBurst = burst != null;
const hasMixedObservers = hasObservatory && hasBurst;
const activeView =
hasBurst && (!hasObservatory || settingsRequireBurstObservatory(templateSettings))
? 'burstObservatory'
: 'observatory';
function patchObservatory(patch: Partial<ObservatoryObject>) {
mutate((tt) => {
tt.observatory = { ...OBSERVATORY_DEFAULTS, ...asObject(tt.observatory), ...patch };
});
}
function patchPingConfig(patch: Partial<PingConfigObject>) {
mutate((tt) => {
const current = asObject(tt.burstObservatory);
const currentPing = asObject(current.pingConfig);
tt.burstObservatory = {
...BURST_DEFAULTS,
...current,
pingConfig: { ...BURST_DEFAULTS.pingConfig, ...currentPing, ...patch },
};
});
}
if (!hasObservatory && !hasBurst) {
return <Empty description={t('pages.xray.observatory.emptyHint')} />;
}
const observatorySection = observatory && (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.subjectSelector')}
description={t('pages.xray.observatory.subjectSelectorDesc')}
>
<SelectorTags tags={observatory.subjectSelector} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.probeURL')}
description={t('pages.xray.observatory.probeURLDesc')}
>
<Input
value={observatory.probeURL}
onChange={(e) => patchObservatory({ probeURL: e.target.value })}
placeholder="https://www.google.com/generate_204"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.probeInterval')}
description={t('pages.xray.observatory.probeIntervalDesc')}
>
<Input
value={observatory.probeInterval}
onChange={(e) => patchObservatory({ probeInterval: e.target.value })}
placeholder="1m"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.enableConcurrency')}
description={t('pages.xray.observatory.enableConcurrencyDesc')}
>
<Switch
checked={observatory.enableConcurrency}
onChange={(v) => patchObservatory({ enableConcurrency: v })}
/>
</SettingListItem>
</>
);
const burstSection = burst && (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.subjectSelector')}
description={t('pages.xray.observatory.subjectSelectorDesc')}
>
<SelectorTags tags={burst.subjectSelector} />
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.destination')}
description={t('pages.xray.observatory.destinationDesc')}
>
<Input
value={burst.pingConfig.destination}
onChange={(e) => patchPingConfig({ destination: e.target.value })}
placeholder="https://www.google.com/generate_204"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.connectivity')}
description={t('pages.xray.observatory.connectivityDesc')}
>
<Input
value={burst.pingConfig.connectivity}
allowClear
onChange={(e) => patchPingConfig({ connectivity: e.target.value })}
placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.interval')}
description={t('pages.xray.observatory.intervalDesc')}
>
<Input
value={burst.pingConfig.interval}
onChange={(e) => patchPingConfig({ interval: e.target.value })}
placeholder="1m"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.timeout')}
description={t('pages.xray.observatory.timeoutDesc')}
>
<Input
value={burst.pingConfig.timeout}
onChange={(e) => patchPingConfig({ timeout: e.target.value })}
placeholder="5s"
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.sampling')}
description={t('pages.xray.observatory.samplingDesc')}
>
<InputNumber
min={1}
value={burst.pingConfig.sampling}
onChange={onNumber((v) => patchPingConfig({ sampling: v }))}
style={{ width: '100%' }}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.xray.observatory.httpMethod')}
description={t('pages.xray.observatory.httpMethodDesc')}
>
<Select<ObservatoryHttpMethod>
value={burst.pingConfig.httpMethod}
onChange={(v) => patchPingConfig({ httpMethod: v })}
options={ObservatoryHttpMethodSchema.options.map((m) => ({ value: m, label: m }))}
style={{ width: '100%' }}
/>
</SettingListItem>
</>
);
return (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Alert type="info" showIcon title={t('pages.xray.observatory.autoManaged')} />
{hasMixedObservers && (
<Alert type="warning" showIcon title={t('pages.xray.observatory.mixedLegacy')} />
)}
<div>{activeView === 'observatory' ? observatorySection : burstSection}</div>
</Space>
);
}
@@ -1,2 +0,0 @@
export { default as BalancersTab } from './BalancersTab';
export { default as BalancerFormModal } from './BalancerFormModal';
@@ -29,16 +29,12 @@ import {
interface BasicsTabProps { interface BasicsTabProps {
templateSettings: XraySettingsValue | null; templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate; setTemplateSettings: SetTemplate;
outboundTestUrl: string;
onChangeOutboundTestUrl: (v: string) => void;
onResetDefault: () => void; onResetDefault: () => void;
} }
export default function BasicsTab({ export default function BasicsTab({
templateSettings, templateSettings,
setTemplateSettings, setTemplateSettings,
outboundTestUrl,
onChangeOutboundTestUrl,
onResetDefault, onResetDefault,
}: BasicsTabProps) { }: BasicsTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -276,18 +272,6 @@ export default function BasicsTab({
/> />
} }
/> />
<SettingListItem
title={t('pages.xray.outboundTestUrl')}
description={t('pages.xray.outboundTestUrlDesc')}
paddings="small"
control={
<Input
value={outboundTestUrl}
onChange={(e) => onChangeOutboundTestUrl(e.target.value)}
placeholder="https://www.google.com/generate_204"
/>
}
/>
</> </>
), ),
}, },
@@ -1,26 +0,0 @@
.preset-warning {
margin-bottom: 12px;
}
.preset-list {
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
overflow: hidden;
}
.preset-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
}
.preset-row:last-child {
border-bottom: 0;
}
.preset-name {
font-weight: 500;
}
@@ -1,113 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Alert, Button, Modal, Space, Tag } from 'antd';
import './DnsPresetsModal.css';
interface DnsPresetsModalProps {
open: boolean;
onClose: () => void;
onInstall: (servers: string[]) => void;
}
export const PRESETS: { name: string; tags: string[]; data: string[] }[] = [
{
name: 'Google DNS',
tags: ['UDP'],
data: ['8.8.8.8', '8.8.4.4', '2001:4860:4860::8888', '2001:4860:4860::8844'],
},
{
name: 'Cloudflare DNS',
tags: ['UDP'],
data: ['1.1.1.1', '1.0.0.1', '2606:4700:4700::1111', '2606:4700:4700::1001'],
},
{
name: 'AdGuard DNS',
tags: ['UDP'],
data: ['94.140.14.14', '94.140.15.15', '2a10:50c0::ad1:ff', '2a10:50c0::ad2:ff'],
},
{
name: 'AdGuard Family DNS',
tags: ['UDP', 'Family'],
data: ['94.140.14.15', '94.140.15.16', '2a10:50c0::bad1:ff', '2a10:50c0::bad2:ff'],
},
{
name: 'Cloudflare Family DNS',
tags: ['UDP', 'Family'],
data: ['1.1.1.3', '1.0.0.3', '2606:4700:4700::1113', '2606:4700:4700::1003'],
},
{
name: 'Cloudflare DoH',
tags: ['DoH'],
data: ['https://cloudflare-dns.com/dns-query'],
},
{
name: 'Google DoH',
tags: ['DoH'],
data: ['https://dns.google/dns-query'],
},
{
name: 'Quad9 Secure DoH',
tags: ['DoH', 'Malware'],
data: ['https://dns.quad9.net/dns-query'],
},
{
name: 'AdGuard DoH + DoQ',
tags: ['DoH', 'DoQ', 'Ads'],
data: ['https://dns.adguard-dns.com/dns-query', 'quic+local://dns.adguard-dns.com'],
},
{
name: 'Control D Ads DoH + DoQ',
tags: ['DoH', 'DoQ', 'Ads'],
data: ['https://freedns.controld.com/p2', 'quic+local://p2.freedns.controld.com'],
},
{
name: 'Control D Family DoH + DoQ',
tags: ['DoH', 'DoQ', 'Family'],
data: ['https://freedns.controld.com/p4', 'quic+local://p4.freedns.controld.com'],
},
];
function tagLabel(tag: string, t: (key: string) => string): string {
if (tag === 'Family') return t('pages.xray.dns.dnsPresetFamily');
return tag;
}
export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresetsModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
title={t('pages.xray.dns.dnsPresetTitle')}
footer={null}
mask={{ closable: false }}
onCancel={onClose}
>
<Alert
type="warning"
showIcon
className="preset-warning"
title={t('pages.xray.dns.dnsLeakWarning')}
/>
<div className="preset-list">
{PRESETS.map((preset) => (
<div key={preset.name} className="preset-row">
<Space size="small" align="center">
{preset.tags.map((tag) => (
<Tag
key={tag}
color={tag === 'Family' ? 'purple' : tag === 'UDP' ? 'orange' : 'green'}
>
{tagLabel(tag, t)}
</Tag>
))}
<span className="preset-name">{preset.name}</span>
</Space>
<Button type="primary" size="small" onClick={() => onInstall([...preset.data])}>
{t('install')}
</Button>
</div>
))}
</div>
</Modal>
);
}
@@ -1,313 +0,0 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { InputAddon } from '@/components/ui';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import {
DnsQueryStrategySchema,
DnsServerObjectInnerSchema,
DnsServerObjectSchema,
isEncryptedDnsAddress,
type DnsServerObject,
} from '@/schemas/dns';
export type DnsServerValue =
| string
| (DnsServerObject & {
expectIPs?: string[];
[key: string]: unknown;
});
interface DnsServerModalProps {
open: boolean;
server: DnsServerValue | null;
isEdit: boolean;
onClose: () => void;
onConfirm: (value: DnsServerValue) => void;
}
const STRATEGIES = DnsQueryStrategySchema.options;
type DnsServerForm = {
address: string;
port: number;
domains: string[];
expectedIPs: string[];
unexpectedIPs: string[];
queryStrategy: string;
skipFallback: boolean;
disableCache: boolean;
finalQuery: boolean;
tag: string;
clientIP: string;
serveStale: boolean;
serveExpiredTTL: number;
timeoutMs: number;
};
function defaultFormValues(): DnsServerForm {
return {
address: 'localhost',
port: 53,
domains: [],
expectedIPs: [],
unexpectedIPs: [],
queryStrategy: 'UseIP',
skipFallback: false,
disableCache: false,
finalQuery: false,
tag: '',
clientIP: '',
serveStale: false,
serveExpiredTTL: 0,
timeoutMs: 4000,
};
}
function valuesFromServer(server: DnsServerValue | null): DnsServerForm {
if (server == null) return defaultFormValues();
if (typeof server === 'string') return { ...defaultFormValues(), address: server };
const parsed = DnsServerObjectSchema.safeParse(server);
const data = parsed.success ? parsed.data : null;
return {
...defaultFormValues(),
...(data ?? {}),
address: (data?.address ?? server.address) || 'localhost',
domains: data?.domains ?? server.domains ?? [],
expectedIPs: data?.expectedIPs ?? server.expectedIPs ?? server.expectIPs ?? [],
unexpectedIPs: data?.unexpectedIPs ?? server.unexpectedIPs ?? [],
queryStrategy: data?.queryStrategy ?? server.queryStrategy ?? 'UseIP',
skipFallback: data?.skipFallback ?? server.skipFallback ?? false,
disableCache: data?.disableCache ?? server.disableCache ?? false,
finalQuery: data?.finalQuery ?? server.finalQuery ?? false,
tag: data?.tag ?? server.tag ?? '',
clientIP: data?.clientIP ?? server.clientIP ?? '',
serveStale: data?.serveStale ?? server.serveStale ?? false,
serveExpiredTTL: data?.serveExpiredTTL ?? server.serveExpiredTTL ?? 0,
timeoutMs: data?.timeoutMs ?? server.timeoutMs ?? 4000,
};
}
function valuesToWire(values: DnsServerForm): DnsServerValue {
const isPlain =
values.domains.length === 0 &&
values.expectedIPs.length === 0 &&
values.unexpectedIPs.length === 0 &&
values.port === 53 &&
values.queryStrategy === 'UseIP' &&
values.skipFallback === false &&
values.disableCache === false &&
values.finalQuery === false &&
!values.tag &&
!values.clientIP &&
values.serveStale === false &&
values.serveExpiredTTL === 0 &&
values.timeoutMs === 4000;
if (isPlain) return values.address;
const out: Record<string, unknown> = {
address: values.address,
domains: values.domains.filter(Boolean),
expectedIPs: values.expectedIPs.filter(Boolean),
unexpectedIPs: values.unexpectedIPs.filter(Boolean),
queryStrategy: values.queryStrategy,
skipFallback: values.skipFallback,
disableCache: values.disableCache,
finalQuery: values.finalQuery,
serveStale: values.serveStale,
serveExpiredTTL: values.serveExpiredTTL,
timeoutMs: values.timeoutMs,
};
if (!isEncryptedDnsAddress(values.address)) out.port = values.port;
if (values.tag) out.tag = values.tag;
if (values.clientIP) out.clientIP = values.clientIP;
return out as DnsServerValue;
}
const shape = DnsServerObjectInnerSchema.shape;
export default function DnsServerModal({
open,
server,
isEdit,
onClose,
onConfirm,
}: DnsServerModalProps) {
const { t } = useTranslation();
const methods = useForm<DnsServerForm>({ defaultValues: defaultFormValues() });
const domains = useWatch({ control: methods.control, name: 'domains' }) ?? [];
const expectedIPs = useWatch({ control: methods.control, name: 'expectedIPs' }) ?? [];
const unexpectedIPs = useWatch({ control: methods.control, name: 'unexpectedIPs' }) ?? [];
useEffect(() => {
if (!open) return;
methods.reset(valuesFromServer(server));
}, [open, server, methods]);
const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
return (
<Modal
open={open}
title={title}
okText={t('confirm')}
cancelText={t('close')}
mask={{ closable: false }}
onOk={methods.handleSubmit((values) => onConfirm(valuesToWire(values)))}
onCancel={onClose}
>
<FormProvider {...methods}>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<FormField
label={t('pages.inbounds.address')}
name="address"
rules={{ validate: rhfZodValidate(shape.address) }}
>
<Input />
</FormField>
<FormField
label={t('pages.inbounds.port')}
name="port"
rules={{ validate: rhfZodValidate(shape.port) }}
>
<InputNumber min={1} max={65535} />
</FormField>
<FormField label={t('pages.xray.dns.tag')} name="tag">
<Input />
</FormField>
<FormField label={t('pages.xray.dns.clientIp')} name="clientIP">
<Input />
</FormField>
<FormField label={t('pages.xray.dns.strategy')} name="queryStrategy">
<Select
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
/>
</FormField>
<FormField
label={t('pages.xray.dns.timeoutMs')}
name="timeoutMs"
rules={{ validate: rhfZodValidate(shape.timeoutMs) }}
>
<InputNumber min={0} step={500} />
</FormField>
<Divider style={{ margin: '5px 0' }} />
<Form.Item label={t('pages.xray.dns.domains')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('domains', [...domains, ''])}
/>
{domains.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`domains.${i}`} noStyle>
<Input />
</FormField>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'domains',
domains.filter((__, idx) => idx !== i),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.dns.expectIPs')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])}
/>
{expectedIPs.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`expectedIPs.${i}`} noStyle>
<Input />
</FormField>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'expectedIPs',
expectedIPs.filter((__, idx) => idx !== i),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])}
/>
{unexpectedIPs.map((_, i) => (
<Space.Compact key={i} block style={{ marginTop: 4 }}>
<FormField name={`unexpectedIPs.${i}`} noStyle>
<Input />
</FormField>
<InputAddon
ariaLabel={t('remove')}
onClick={() =>
methods.setValue(
'unexpectedIPs',
unexpectedIPs.filter((__, idx) => idx !== i),
)
}
>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
<Divider style={{ margin: '5px 0' }} />
<FormField
label={t('pages.xray.dns.skipFallback')}
name="skipFallback"
valueProp="checked"
>
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.finalQuery')} name="finalQuery" valueProp="checked">
<Switch />
</FormField>
<FormField
label={t('pages.xray.dns.disableCache')}
name="disableCache"
valueProp="checked"
>
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.serveStale')} name="serveStale" valueProp="checked">
<Switch />
</FormField>
<FormField label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
<InputNumber min={0} step={60} />
</FormField>
</Form>
</FormProvider>
</Modal>
);
}
-16
View File
@@ -1,16 +0,0 @@
.row-index {
font-weight: 500;
opacity: 0.7;
}
.muted {
opacity: 0.7;
word-break: break-all;
}
.hosts-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
-516
View File
@@ -1,516 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Empty,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Table,
Tabs,
} from 'antd';
import {
DatabaseOutlined,
DeleteOutlined,
ExperimentOutlined,
MenuOutlined,
PlusOutlined,
ProfileOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { onNumber } from '@/utils/onNumber';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import DnsServerModal from './DnsServerModal';
import type { DnsServerValue } from './DnsServerModal';
import DnsPresetsModal from './DnsPresetsModal';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './DnsTab.css';
import { STRATEGIES, DEFAULT_FAKEDNS } from './helpers';
import type { DnsConfig, HostRow, FakednsRow } from './types';
import { useDnsServerColumns, useFakednsColumns } from './useDnsColumns';
interface DnsTabProps {
templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate;
}
export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [hostsList, setHostsList] = useState<HostRow[]>([]);
const [serverModalOpen, setServerModalOpen] = useState(false);
const [editingServer, setEditingServer] = useState<DnsServerValue | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [presetsModalOpen, setPresetsModalOpen] = useState(false);
const dns = (templateSettings?.dns as DnsConfig | undefined) ?? null;
const dnsEnabled = !!dns;
const sourceHosts = dns?.hosts;
const incomingHosts = JSON.stringify(sourceHosts ?? {});
const lastWrittenHostsRef = useRef<string | null>(null);
useEffect(() => {
if (!dnsEnabled) {
lastWrittenHostsRef.current = '{}';
setHostsList([]);
return;
}
if (incomingHosts === lastWrittenHostsRef.current) return;
lastWrittenHostsRef.current = incomingHosts;
setHostsList(
Object.entries(sourceHosts ?? {}).map(([domain, values]) => ({
domain,
values: Array.isArray(values) ? [...values] : [String(values)],
})),
);
}, [dnsEnabled, incomingHosts, sourceHosts]);
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
setTemplateSettings((prev) => {
if (!prev) return prev;
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
mutator(clone);
return clone;
});
},
[setTemplateSettings],
);
function toggleDNS(enabled: boolean) {
mutate((next) => {
if (enabled) {
(next as { dns?: DnsConfig }).dns = {
tag: 'dns_inbound',
queryStrategy: 'UseIP',
disableCache: false,
disableFallback: false,
disableFallbackIfMatch: false,
useSystemHosts: false,
enableParallelQuery: false,
serveStale: false,
serveExpiredTTL: 0,
hosts: {},
servers: [],
};
next.fakedns = null;
} else {
delete next.dns;
delete next.fakedns;
}
});
}
function syncHosts(next: HostRow[]) {
const obj: Record<string, string | string[]> = {};
for (const row of next) {
if (!row.domain) continue;
const vals = (row.values || []).filter(Boolean);
if (vals.length === 0) continue;
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
}
lastWrittenHostsRef.current = JSON.stringify(obj);
setHostsList(next);
mutate((tt) => {
if (!tt.dns) return;
if (Object.keys(obj).length > 0) {
(tt.dns as DnsConfig).hosts = obj;
} else if ('hosts' in (tt.dns as DnsConfig)) {
delete (tt.dns as DnsConfig).hosts;
}
});
}
function setDnsField<K extends keyof DnsConfig>(key: K, value: DnsConfig[K], omit = false) {
mutate((tt) => {
if (!tt.dns) return;
if (omit && (value == null || (typeof value === 'string' && value.trim() === ''))) {
delete (tt.dns as Record<string, unknown>)[key as string];
} else {
(tt.dns as Record<string, unknown>)[key as string] = value;
}
});
}
const dnsServers = useMemo(() => {
const list = dns?.servers || [];
return list.map((server, idx) => ({ key: idx, server }));
}, [dns?.servers]);
// Stable callbacks: the column definitions in useDnsServerColumns are
// memoized, so they must be able to depend on these (see issue #5155)
const openEditServer = useCallback(
(idx: number) => {
setEditingServer((dns?.servers || [])[idx] || null);
setEditingIndex(idx);
setServerModalOpen(true);
},
[dns?.servers],
);
const deleteServer = useCallback(
(idx: number) => {
mutate((tt) => {
const cfg = tt.dns as DnsConfig | undefined;
if (cfg?.servers) cfg.servers.splice(idx, 1);
});
},
[mutate],
);
const dnsColumns = useDnsServerColumns({ openEditServer, deleteServer });
function openAddServer() {
setEditingServer(null);
setEditingIndex(null);
setServerModalOpen(true);
}
function onServerConfirm(value: DnsServerValue) {
mutate((tt) => {
if (!tt.dns) return;
const cfg = tt.dns as DnsConfig;
if (!Array.isArray(cfg.servers)) cfg.servers = [];
if (editingIndex == null) cfg.servers.push(value);
else cfg.servers[editingIndex] = value;
});
setServerModalOpen(false);
}
function clearAllServers() {
modal.confirm({
title: t('pages.xray.dns.clearAllTitle'),
content: t('pages.xray.dns.clearAllConfirm'),
okText: t('delete'),
okButtonProps: { danger: true },
cancelText: t('cancel'),
onOk: () =>
mutate((tt) => {
if (tt.dns) (tt.dns as DnsConfig).servers = [];
}),
});
}
function onPresetInstall(servers: string[]) {
mutate((tt) => {
if (tt.dns) (tt.dns as DnsConfig).servers = servers;
});
setPresetsModalOpen(false);
}
const fakeDnsList = useMemo<{ key: number; ipPool: string; poolSize: number }[]>(() => {
const list = Array.isArray(templateSettings?.fakedns)
? (templateSettings?.fakedns as FakednsRow[])
: [];
return list.map((entry, idx) => ({ key: idx, ...entry }));
}, [templateSettings?.fakedns]);
const deleteFakedns = useCallback(
(idx: number) => {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list) return;
list.splice(idx, 1);
if (list.length === 0) tt.fakedns = null;
});
},
[mutate],
);
const updateFakednsField = useCallback(
(idx: number, field: 'ipPool' | 'poolSize', value: string | number) => {
mutate((tt) => {
const list = tt.fakedns as FakednsRow[] | undefined;
if (!list?.[idx]) return;
(list[idx] as unknown as Record<string, unknown>)[field] = value;
});
},
[mutate],
);
const fakednsColumns = useFakednsColumns({ deleteFakedns, updateFakednsField });
function addFakedns() {
mutate((tt) => {
if (!Array.isArray(tt.fakedns)) tt.fakedns = [];
(tt.fakedns as FakednsRow[]).push(DEFAULT_FAKEDNS());
});
}
const items = useMemo(() => {
const out = [
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.enable')}
description={t('pages.xray.dns.enableDesc')}
control={<Switch checked={dnsEnabled} onChange={toggleDNS} />}
/>
{dnsEnabled && (
<>
<Alert
type="warning"
showIcon
title={t('pages.xray.dns.dnsLeakWarning')}
style={{ marginBottom: 12 }}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.tag')}
description={t('pages.xray.dns.tagDesc')}
control={
<Input
value={dns?.tag ?? 'dns_inbound'}
onChange={(e) => setDnsField('tag', e.target.value)}
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.clientIp')}
description={t('pages.xray.dns.clientIpDesc')}
control={
<Input
value={dns?.clientIp ?? ''}
onChange={(e) => setDnsField('clientIp', e.target.value, true)}
/>
}
/>
<SettingListItem
paddings="small"
title={t('pages.xray.dns.strategy')}
description={t('pages.xray.dns.strategyDesc')}
control={
<Select
value={dns?.queryStrategy ?? 'UseIP'}
style={{ width: '100%' }}
options={STRATEGIES.map((s) => ({ value: s, label: s }))}
onChange={(v) => setDnsField('queryStrategy', v)}
/>
}
/>
{(
[
[
'disableCache',
'pages.xray.dns.disableCache',
'pages.xray.dns.disableCacheDesc',
],
[
'disableFallback',
'pages.xray.dns.disableFallback',
'pages.xray.dns.disableFallbackDesc',
],
[
'disableFallbackIfMatch',
'pages.xray.dns.disableFallbackIfMatch',
'pages.xray.dns.disableFallbackIfMatchDesc',
],
[
'enableParallelQuery',
'pages.xray.dns.enableParallelQuery',
'pages.xray.dns.enableParallelQueryDesc',
],
[
'useSystemHosts',
'pages.xray.dns.useSystemHosts',
'pages.xray.dns.useSystemHostsDesc',
],
['serveStale', 'pages.xray.dns.serveStale', 'pages.xray.dns.serveStaleDesc'],
] as const
).map(([field, titleKey, descKey]) => (
<SettingListItem
key={field}
paddings="small"
title={t(titleKey)}
description={t(descKey)}
control={
<Switch
checked={!!dns?.[field]}
onChange={(v) => setDnsField(field as keyof DnsConfig, v as never)}
/>
}
/>
))}
<SettingListItem
paddings="small"
title={t('pages.xray.dns.serveExpiredTTL')}
description={t('pages.xray.dns.serveExpiredTTLDesc')}
control={
<InputNumber
value={dns?.serveExpiredTTL ?? 0}
min={0}
step={60}
style={{ width: '100%' }}
onChange={onNumber((v) => setDnsField('serveExpiredTTL', v))}
/>
}
/>
</>
)}
</>
),
},
];
if (dnsEnabled) {
out.push({
key: 'hosts',
label: catTabLabel(<ProfileOutlined />, t('pages.xray.dns.hosts'), isMobile),
children:
hostsList.length === 0 ? (
<Empty description={t('pages.xray.dns.hostsEmpty')}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
>
{t('pages.xray.dns.hostsAdd')}
</Button>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}
>
{t('pages.xray.dns.hostsAdd')}
</Button>
{hostsList.map((row, idx) => (
<div key={`h${idx}`} className="hosts-row">
<Input
value={row.domain}
aria-label={t('pages.xray.dns.hostsDomain')}
placeholder={t('pages.xray.dns.hostsDomain')}
style={{ flex: '1 1 220px' }}
onChange={(e) => {
const next = hostsList.map((r, i) =>
i === idx ? { ...r, domain: e.target.value } : r,
);
syncHosts(next);
}}
/>
<Select
mode="tags"
value={row.values}
aria-label={t('pages.xray.dns.hostsValues')}
placeholder={t('pages.xray.dns.hostsValues')}
style={{ flex: '2 1 320px' }}
tokenSeparators={[',', ' ']}
onChange={(values) => {
const next = hostsList.map((r, i) => (i === idx ? { ...r, values } : r));
syncHosts(next);
}}
/>
<Button
danger
aria-label={t('delete')}
icon={<DeleteOutlined />}
onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))}
/>
</div>
))}
</Space>
),
});
out.push({
key: '2',
label: catTabLabel(<DatabaseOutlined />, 'DNS', isMobile),
children:
dnsServers.length === 0 ? (
<Empty description={t('emptyDnsDesc')}>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
{t('pages.xray.dns.add')}
</Button>
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
{t('pages.xray.dns.usePreset')}
</Button>
</Space>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAddServer}>
{t('pages.xray.dns.add')}
</Button>
<Button icon={<MenuOutlined />} onClick={() => setPresetsModalOpen(true)}>
{t('pages.xray.dns.usePreset')}
</Button>
<Button danger icon={<DeleteOutlined />} onClick={clearAllServers}>
{t('pages.xray.dns.clearAll')}
</Button>
</Space>
<Table
columns={dnsColumns}
dataSource={dnsServers}
rowKey={(r) => r.key}
pagination={false}
size="small"
bordered
/>
</Space>
),
});
out.push({
key: '3',
label: catTabLabel(<ExperimentOutlined />, 'Fake DNS', isMobile),
children:
fakeDnsList.length === 0 ? (
<Empty description={t('emptyFakeDnsDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')}
</Button>
</Empty>
) : (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
{t('pages.xray.fakedns.add')}
</Button>
<Table
columns={fakednsColumns}
dataSource={fakeDnsList}
rowKey={(r) => r.key}
pagination={false}
size="small"
bordered
/>
</Space>
),
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, isMobile, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
return (
<>
{modalContextHolder}
<Tabs defaultActiveKey="1" items={items} />
<DnsServerModal
open={serverModalOpen}
server={editingServer}
isEdit={editingIndex != null}
onClose={() => setServerModalOpen(false)}
onConfirm={onServerConfirm}
/>
<DnsPresetsModal
open={presetsModalOpen}
onClose={() => setPresetsModalOpen(false)}
onInstall={onPresetInstall}
/>
</>
);
}
-19
View File
@@ -1,19 +0,0 @@
import { DnsQueryStrategySchema } from '@/schemas/dns';
import type { DnsServerValue } from './DnsServerModal';
export const STRATEGIES = DnsQueryStrategySchema.options;
export const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
export function addrFor(server: DnsServerValue): string {
return typeof server === 'string' ? server : server?.address || '';
}
export function domainsFor(server: DnsServerValue): string {
return typeof server === 'object' && server !== null ? (server.domains || []).join(',') : '';
}
export function expectedIPsFor(server: DnsServerValue): string {
if (typeof server !== 'object' || !server) return '';
const list = server.expectedIPs || server.expectIPs || [];
return Array.isArray(list) ? list.join(',') : '';
}
-3
View File
@@ -1,3 +0,0 @@
export { default as DnsTab } from './DnsTab';
export { default as DnsServerModal } from './DnsServerModal';
export { default as DnsPresetsModal } from './DnsPresetsModal';
-14
View File
@@ -1,14 +0,0 @@
import type { DnsObject } from '@/schemas/dns';
import type { DnsServerValue } from './DnsServerModal';
export type DnsConfig = Omit<DnsObject, 'servers'> & { servers?: DnsServerValue[] };
export interface HostRow {
domain: string;
values: string[];
}
export interface FakednsRow {
ipPool: string;
poolSize: number;
}
@@ -1,149 +0,0 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Input, InputNumber, Space } from 'antd';
import { MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { onNumber } from '@/utils/onNumber';
import { addrFor, domainsFor, expectedIPsFor } from './helpers';
import type { DnsServerValue } from './DnsServerModal';
export type DnsServerRow = { key: number; server: DnsServerValue };
export type FakednsTableRow = { key: number; ipPool: string; poolSize: number };
export function useDnsServerColumns({
openEditServer,
deleteServer,
}: {
openEditServer: (idx: number) => void;
deleteServer: (idx: number) => void;
}): ColumnsType<DnsServerRow> {
const { t } = useTranslation();
return useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
</>
),
onClick: () => openEditServer(index),
},
{
key: 'del',
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
</>
),
onClick: () => deleteServer(index),
},
],
}}
>
<Button aria-label={t('more')} shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</Space>
),
},
{
title: t('pages.inbounds.address'),
key: 'address',
align: 'left',
render: (_v, record) => addrFor(record.server),
},
{
title: t('pages.xray.dns.domains'),
key: 'domains',
align: 'left',
render: (_v, record) => <span className="muted">{domainsFor(record.server)}</span>,
},
{
title: t('pages.xray.dns.expectIPs'),
key: 'expectedIPs',
align: 'left',
render: (_v, record) => <span className="muted">{expectedIPsFor(record.server)}</span>,
},
],
[t, openEditServer, deleteServer],
);
}
export function useFakednsColumns({
deleteFakedns,
updateFakednsField,
}: {
deleteFakedns: (idx: number) => void;
updateFakednsField: (idx: number, field: 'ipPool' | 'poolSize', value: string | number) => void;
}): ColumnsType<FakednsTableRow> {
const { t } = useTranslation();
return useMemo(
() => [
{
title: '#',
key: 'action',
align: 'center',
width: 60,
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Button
aria-label={t('delete')}
shape="circle"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => deleteFakedns(index)}
/>
</Space>
),
},
{
title: 'IP pool',
dataIndex: 'ipPool',
key: 'ipPool',
align: 'left',
render: (_v, record, index) => (
<Input
value={record.ipPool}
aria-label={t('pages.xray.fakedns.ipPool')}
size="small"
onChange={(e) => updateFakednsField(index, 'ipPool', e.target.value)}
/>
),
},
{
title: 'Pool size',
dataIndex: 'poolSize',
key: 'poolSize',
align: 'right',
width: 120,
render: (_v, record, index) => (
<InputNumber
value={record.poolSize}
aria-label={t('pages.xray.fakedns.poolSize')}
min={1}
size="small"
onChange={onNumber((v) => updateFakednsField(index, 'poolSize', v))}
/>
),
},
],
[t, deleteFakedns, updateFakednsField],
);
}
@@ -1,18 +0,0 @@
import { CloudOutlined } from '@ant-design/icons';
interface CountryPillProps {
flag: string;
name: string;
warp?: string;
}
export default function CountryPill({ flag, name, warp }: CountryPillProps) {
const isWarp = !!warp && warp.toLowerCase() !== 'off';
return (
<span className={isWarp ? 'country-pill warp-on' : 'country-pill'}>
{isWarp && <CloudOutlined className="warp-cloud-icon" />}
{flag && <span>{flag}</span>}
<span>{name}</span>
</span>
);
}
@@ -1,4 +1,3 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag, Tooltip } from 'antd'; import { Button, Dropdown, Tag, Tooltip } from 'antd';
import { import {
@@ -7,127 +6,34 @@ import {
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
VerticalAlignTopOutlined, VerticalAlignTopOutlined,
ThunderboltOutlined,
LoadingOutlined,
ExportOutlined, ExportOutlined,
EyeInvisibleOutlined,
EyeOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { SizeFormatter } from '@/utils'; import { SizeFormatter } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { OutboundProtocols as Protocols } from '@/schemas/primitives'; import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
OutboundTestMode,
OutboundTestState,
OutboundTrafficRow,
} from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types'; import type { OutboundRow } from './outbounds-tab-types';
import CountryPill from './CountryPill'; import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
import TestResultPopover from './TestResultPopover';
import {
countryFlag,
countryName,
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
interface OutboundCardListProps { interface OutboundCardListProps {
rows: OutboundRow[]; rows: OutboundRow[];
testMode: OutboundTestMode;
outboundsTraffic: OutboundTrafficRow[]; outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
setFirst: (idx: number) => void; setFirst: (idx: number) => void;
openEdit: (idx: number) => void; openEdit: (idx: number) => void;
onResetTraffic: (tag: string) => void; onResetTraffic: (tag: string) => void;
confirmDelete: (idx: number) => void; confirmDelete: (idx: number) => void;
onTest: (index: number, mode: string) => void;
} }
export default function OutboundCardList({ export default function OutboundCardList({
rows, rows,
testMode,
outboundsTraffic, outboundsTraffic,
outboundTestStates,
setFirst, setFirst,
openEdit, openEdit,
onResetTraffic, onResetTraffic,
confirmDelete, confirmDelete,
onTest,
}: OutboundCardListProps) { }: OutboundCardListProps) {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const [showEgressIp, setShowEgressIp] = useState<Record<string, boolean>>({});
const setCardEgressVisible = (key: string, visible: boolean) => {
setShowEgressIp((prev) => ({ ...prev, [key]: visible }));
};
const renderEgress = (testKey: number, rowKey: string) => {
const result = testResult(outboundTestStates, testKey);
const egress = result?.egress;
const isEgressVisible = !!showEgressIp[rowKey];
const flag = countryFlag(egress?.country);
const name = countryName(egress?.country, i18n.language);
const addresses = [
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
].filter((item): item is { label: string; value: string } => Boolean(item));
if (!egress || (addresses.length === 0 && !egress.country)) {
return null;
}
return (
<div className="card-egress">
<div className="card-egress-row">
<span>{t('pages.xray.outbound.egress')}:</span>
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{isEgressVisible ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setCardEgressVisible(rowKey, false)}
onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, false))}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setCardEgressVisible(rowKey, true)}
onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, true))}
/>
)}
</Tooltip>
{egress.country && (
<CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
)}
</div>
{addresses.map((addr) => (
<Tooltip key={addr.label} title={addr.value}>
<div className="card-egress-row">
<span className="egress-family">{addr.label}:</span>
<span
className={
isEgressVisible ? 'address-visible egress-ip' : 'address-hidden egress-ip'
}
>
{addr.value}
</span>
</div>
</Tooltip>
))}
</div>
);
};
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
@@ -219,7 +125,6 @@ export default function OutboundCardList({
))} ))}
</div> </div>
)} )}
{renderEgress(record.key, String(record.key))}
<div className="card-foot"> <div className="card-foot">
<span className="traffic-up"> <span className="traffic-up">
↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)} ↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}
@@ -228,23 +133,6 @@ export default function OutboundCardList({
<span className="traffic-down"> <span className="traffic-down">
↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)} ↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}
</span> </span>
<span className="card-test">
{testResult(outboundTestStates, record.key) ? (
<TestResultPopover result={testResult(outboundTestStates, record.key)!} />
) : isTesting(outboundTestStates, record.key) ? (
<LoadingOutlined />
) : null}
<Button
type="primary"
shape="circle"
size="small"
loading={isTesting(outboundTestStates, record.key)}
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
icon={<ThunderboltOutlined />}
aria-label={t('check')}
onClick={() => onTest(record.key, testMode)}
/>
</span>
</div> </div>
</div> </div>
))} ))}
@@ -75,51 +75,8 @@
background: var(--ant-color-fill-tertiary); background: var(--ant-color-fill-tertiary);
} }
.egress-header {
display: inline-flex;
align-items: center;
gap: 4px;
}
.egress-ip {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.egress-stack {
display: inline-flex;
flex-direction: column;
gap: 3px;
}
.egress-address {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.egress-family {
color: var(--ant-color-text-secondary);
font-size: 10px;
text-transform: uppercase;
}
.country-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--ant-color-border);
border-radius: 4px;
padding: 1px 6px;
line-height: 20px;
font-size: 12px;
white-space: nowrap;
}
.country-pill.warp-on {
border-color: #f48120;
}
.warp-cloud-icon { .warp-cloud-icon {
width: 1em; width: 1em;
@@ -185,30 +142,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.card-egress {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
font-size: 12px;
opacity: 0.85;
}
.card-egress-row {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
}
.card-test {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 8px;
}
.traffic-up { .traffic-up {
color: #008771; color: #008771;
font-size: 12px; font-size: 12px;
@@ -9,7 +9,6 @@ import {
InputNumber, InputNumber,
Modal, Modal,
Popconfirm, Popconfirm,
Radio,
Row, Row,
Space, Space,
Switch, Switch,
@@ -24,7 +23,6 @@ import {
ApiOutlined, ApiOutlined,
MoreOutlined, MoreOutlined,
RetweetOutlined, RetweetOutlined,
PlayCircleOutlined,
ReloadOutlined, ReloadOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
@@ -47,13 +45,7 @@ import { propagateOutboundTagRename } from '../basics/helpers';
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup'; import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
import DeletionImpactList from '../DeletionImpactList'; import DeletionImpactList from '../DeletionImpactList';
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback'; import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
import type { import type { XraySettingsValue, SetTemplate, OutboundTrafficRow } from '@/hooks/useXraySetting';
XraySettingsValue,
SetTemplate,
OutboundTestMode,
OutboundTestState,
OutboundTrafficRow,
} from '@/hooks/useXraySetting';
import './OutboundsTab.css'; import './OutboundsTab.css';
import type { OutboundRow } from './outbounds-tab-types'; import type { OutboundRow } from './outbounds-tab-types';
@@ -82,17 +74,11 @@ interface OutboundsTabProps {
templateSettings: XraySettingsValue | null; templateSettings: XraySettingsValue | null;
setTemplateSettings: SetTemplate; setTemplateSettings: SetTemplate;
outboundsTraffic: OutboundTrafficRow[]; outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
subscriptionTestStates: Record<string, OutboundTestState>;
testingAll: boolean;
inboundTags: string[]; inboundTags: string[];
subscriptionOutbounds?: unknown[]; subscriptionOutbounds?: unknown[];
subscriptionOutboundTags?: string[]; subscriptionOutboundTags?: string[];
isMobile: boolean; isMobile: boolean;
onResetTraffic: (tag: string) => void; onResetTraffic: (tag: string) => void;
onTest: (index: number, mode: string) => void;
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
onTestAll: (mode: string) => void;
onShowWarp: () => void; onShowWarp: () => void;
onShowNord: () => void; onShowNord: () => void;
onShowPia: () => void; onShowPia: () => void;
@@ -103,17 +89,11 @@ export default function OutboundsTab({
templateSettings, templateSettings,
setTemplateSettings, setTemplateSettings,
outboundsTraffic, outboundsTraffic,
outboundTestStates,
subscriptionTestStates,
testingAll,
inboundTags: _inboundTags, inboundTags: _inboundTags,
subscriptionOutbounds, subscriptionOutbounds,
subscriptionOutboundTags, subscriptionOutboundTags,
isMobile, isMobile,
onResetTraffic, onResetTraffic,
onTest,
onTestSubscription,
onTestAll,
onShowWarp, onShowWarp,
onShowNord, onShowNord,
onShowPia, onShowPia,
@@ -122,7 +102,6 @@ export default function OutboundsTab({
const { t } = useTranslation(); const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
const [testMode, setTestMode] = useState<OutboundTestMode>('tcp');
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null); const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null); const [editingIndex, setEditingIndex] = useState<number | null>(null);
@@ -519,17 +498,14 @@ export default function OutboundsTab({
} }
const columns = useOutboundColumns({ const columns = useOutboundColumns({
testMode,
rows, rows,
outboundsTraffic, outboundsTraffic,
outboundTestStates,
openEdit, openEdit,
setFirst, setFirst,
moveUp, moveUp,
moveDown, moveDown,
confirmDelete, confirmDelete,
onResetTraffic, onResetTraffic,
onTest,
}); });
return ( return (
@@ -581,26 +557,6 @@ export default function OutboundsTab({
</Col> </Col>
<Col xs={24} sm={12} className="toolbar-right"> <Col xs={24} sm={12} className="toolbar-right">
<Space size="small" wrap> <Space size="small" wrap>
<Tooltip title={t('pages.xray.outbound.testModeTooltip')}>
<Radio.Group
value={testMode}
onChange={(e) => setTestMode(e.target.value)}
buttonStyle="solid"
size="small"
>
<Radio.Button value="tcp">TCP</Radio.Button>
<Radio.Button value="http">HTTP</Radio.Button>
<Radio.Button value="real">{t('pages.xray.outbound.modeRealDelay')}</Radio.Button>
</Radio.Group>
</Tooltip>
<Button
type="primary"
loading={testingAll}
icon={<PlayCircleOutlined />}
onClick={() => onTestAll(testMode)}
>
{!isMobile && t('pages.xray.outbound.testAll')}
</Button>
<Popconfirm <Popconfirm
placement="topRight" placement="topRight"
okText={t('reset')} okText={t('reset')}
@@ -617,14 +573,11 @@ export default function OutboundsTab({
{isMobile ? ( {isMobile ? (
<OutboundCardList <OutboundCardList
rows={rows} rows={rows}
testMode={testMode}
outboundsTraffic={outboundsTraffic} outboundsTraffic={outboundsTraffic}
outboundTestStates={outboundTestStates}
setFirst={setFirst} setFirst={setFirst}
openEdit={openEdit} openEdit={openEdit}
onResetTraffic={onResetTraffic} onResetTraffic={onResetTraffic}
confirmDelete={confirmDelete} confirmDelete={confirmDelete}
onTest={onTest}
/> />
) : ( ) : (
<Table <Table
@@ -675,10 +628,7 @@ export default function OutboundsTab({
<SubscriptionOutbounds <SubscriptionOutbounds
subscriptionOutbounds={subscriptionOutbounds} subscriptionOutbounds={subscriptionOutbounds}
outboundsTraffic={outboundsTraffic} outboundsTraffic={outboundsTraffic}
subscriptionTestStates={subscriptionTestStates}
testMode={testMode}
isMobile={isMobile} isMobile={isMobile}
onTestSubscription={onTestSubscription}
/> />
)} )}
</Space> </Space>
@@ -1,49 +1,28 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Table, Tag, Tooltip } from 'antd'; import { Table, Tag, Tooltip } from 'antd';
import { ThunderboltOutlined, LoadingOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils'; import { SizeFormatter } from '@/utils';
import { OutboundProtocols as Protocols } from '@/schemas/primitives'; import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
OutboundTestMode,
OutboundTestState,
OutboundTrafficRow,
} from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types'; import type { OutboundRow } from './outbounds-tab-types';
import TestResultPopover from './TestResultPopover'; import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
import {
effectiveTestMode,
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testModeLabel,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
interface SubscriptionOutboundsProps { interface SubscriptionOutboundsProps {
subscriptionOutbounds: unknown[]; subscriptionOutbounds: unknown[];
outboundsTraffic: OutboundTrafficRow[]; outboundsTraffic: OutboundTrafficRow[];
subscriptionTestStates: Record<string, OutboundTestState>;
testMode: OutboundTestMode;
isMobile: boolean; isMobile: boolean;
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
} }
// Read-only view of outbounds imported from active subscriptions. They are not // Read-only view of outbounds imported from active subscriptions. They are not
// part of the editable template (so no edit/delete/move), but traffic is matched // part of the editable template (so no edit/delete/move), but traffic is matched
// by tag and they can be latency-tested via the same backend endpoint. // by tag.
export default function SubscriptionOutbounds({ export default function SubscriptionOutbounds({
subscriptionOutbounds, subscriptionOutbounds,
outboundsTraffic, outboundsTraffic,
subscriptionTestStates,
testMode,
isMobile, isMobile,
onTestSubscription,
}: SubscriptionOutboundsProps) { }: SubscriptionOutboundsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -104,36 +83,6 @@ export default function SubscriptionOutbounds({
); );
}; };
const latencyCell = (record: OutboundRow) => {
const key = record.tag || '';
const r = testResult(subscriptionTestStates, key);
if (!r)
return isTesting(subscriptionTestStates, key) ? (
<LoadingOutlined />
) : (
<span className="empty">—</span>
);
return <TestResultPopover result={r} />;
};
const testButton = (record: OutboundRow) => {
const key = record.tag || '';
return (
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
<Button
aria-label={t('check')}
type="primary"
shape="circle"
size={isMobile ? 'small' : undefined}
loading={isTesting(subscriptionTestStates, key)}
disabled={!record.tag || isUntestable(record) || isTesting(subscriptionTestStates, key)}
icon={<ThunderboltOutlined />}
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
/>
</Tooltip>
);
};
const header = ( const header = (
<div className="subscription-outbounds-head"> <div className="subscription-outbounds-head">
<div className="subscription-outbounds-title"> <div className="subscription-outbounds-title">
@@ -154,13 +103,9 @@ export default function SubscriptionOutbounds({
<span className="card-num">{index + 1}</span> <span className="card-num">{index + 1}</span>
{identityCell(record)} {identityCell(record)}
</div> </div>
{testButton(record)}
</div> </div>
{outboundAddresses(record).length > 0 && addressCell(record)} {outboundAddresses(record).length > 0 && addressCell(record)}
<div className="card-foot"> <div className="card-foot">{trafficCell(record)}</div>
{trafficCell(record)}
<span className="card-test">{latencyCell(record)}</span>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -194,20 +139,6 @@ export default function SubscriptionOutbounds({
width: 200, width: 200,
render: (_v, record) => trafficCell(record), render: (_v, record) => trafficCell(record),
}, },
{
title: t('pages.nodes.latency'),
key: 'testResult',
align: 'left',
width: 140,
render: (_v, record) => latencyCell(record),
},
{
title: t('check'),
key: 'test',
align: 'center',
width: 80,
render: (_v, record) => testButton(record),
},
]; ];
return ( return (
@@ -1,88 +0,0 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Popover } from 'antd';
import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
import type { OutboundTestResult } from '@/hooks/useXraySetting';
import { testModeLabel } from './outbounds-tab-helpers';
interface TestResultPopoverProps {
result: OutboundTestResult;
// Custom trigger element; defaults to the ok/fail latency pill.
children?: ReactNode;
}
// Latency pill + detail popover for an outbound test result: per-endpoint
// dial outcomes for TCP probes, HTTP status and the timing breakdown for
// HTTP probes.
export default function TestResultPopover({ result: r, children }: TestResultPopoverProps) {
const { t } = useTranslation();
const breakdown: Array<{ key: string; label: string; value: string }> = [];
if (typeof r.httpStatus === 'number') {
breakdown.push({
key: 'status',
label: t('pages.xray.outbound.httpStatus'),
value: String(r.httpStatus),
});
}
if (typeof r.connectMs === 'number') {
breakdown.push({
key: 'connect',
label: t('pages.xray.outbound.breakdownConnect'),
value: `${r.connectMs} ms`,
});
}
if (typeof r.tlsMs === 'number') {
breakdown.push({
key: 'tls',
label: t('pages.xray.outbound.breakdownTls'),
value: `${r.tlsMs} ms`,
});
}
if (typeof r.ttfbMs === 'number') {
breakdown.push({
key: 'ttfb',
label: t('pages.xray.outbound.breakdownTtfb'),
value: `${r.ttfbMs} ms`,
});
}
return (
<Popover
placement="topLeft"
rootClassName="outbound-test-popover"
content={
<div className="timing-breakdown">
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
{r.mode && <span className="mode-badge">{testModeLabel(String(r.mode), t)}</span>}
</div>
{(r.endpoints || []).map((ep) => (
<div key={ep.address} className="endpoint-row">
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
<span className="ep-addr">{ep.address}</span>
<span className="ep-meta">
{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}
</span>
</div>
))}
{breakdown.map((row) => (
<div key={row.key} className="breakdown-row">
<span className="bd-label">{row.label}</span>
<span className="bd-value">{row.value}</span>
</div>
))}
</div>
}
>
{children ?? (
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
{r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
</span>
)}
</Popover>
);
}
@@ -1,12 +1,5 @@
import type { TFunction } from 'i18next';
import { OutboundProtocols as Protocols } from '@/schemas/primitives'; import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import { isUdpOutbound } from '@/hooks/useXraySetting'; import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
import type {
OutboundTestMode,
OutboundTestState,
OutboundTrafficRow,
} from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types'; import type { OutboundRow } from './outbounds-tab-types';
@@ -54,33 +47,10 @@ export function outboundAddresses(o: OutboundRow): string[] {
} }
} }
export function isUntestable(o: OutboundRow): boolean {
if (!o) return true;
if (
o.protocol === Protocols.Blackhole ||
o.protocol === Protocols.Loopback ||
o.tag === 'blocked'
)
return true;
// freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and
// an HTTP probe would only measure the host's own direct reachability, so
// they're untestable in every mode.
if (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS) return true;
return false;
}
export function showSecurity(security?: string): boolean { export function showSecurity(security?: string): boolean {
return security === 'tls' || security === 'reality'; return security === 'tls' || security === 'reality';
} }
export function effectiveTestMode(o: unknown, mode: OutboundTestMode): OutboundTestMode {
return mode === 'tcp' && isUdpOutbound(o) ? 'http' : mode;
}
export function testModeLabel(mode: string, t: TFunction): string {
return mode === 'real' ? t('pages.xray.outbound.modeRealDelay') : mode.toUpperCase();
}
export function trafficFor( export function trafficFor(
outboundsTraffic: OutboundTrafficRow[], outboundsTraffic: OutboundTrafficRow[],
o: OutboundRow, o: OutboundRow,
@@ -106,17 +76,3 @@ export function countryName(country?: string, locale?: string): string {
return code; return code;
} }
} }
export function isTesting<K extends string | number>(
states: Record<K, OutboundTestState>,
idx: K,
): boolean {
return !!states?.[idx]?.testing;
}
export function testResult<K extends string | number>(
states: Record<K, OutboundTestState>,
idx: K,
) {
return states?.[idx]?.result || null;
}
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Tag, Tooltip } from 'antd'; import { Button, Dropdown, Tag, Tooltip } from 'antd';
import { import {
@@ -7,69 +7,40 @@ import {
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
VerticalAlignTopOutlined, VerticalAlignTopOutlined,
ThunderboltOutlined,
LoadingOutlined,
ArrowUpOutlined, ArrowUpOutlined,
ArrowDownOutlined, ArrowDownOutlined,
EyeInvisibleOutlined,
EyeOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { SizeFormatter } from '@/utils'; import { SizeFormatter } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { OutboundProtocols as Protocols } from '@/schemas/primitives'; import { OutboundProtocols as Protocols } from '@/schemas/primitives';
import type { import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
OutboundTestMode,
OutboundTestState,
OutboundTrafficRow,
} from '@/hooks/useXraySetting';
import type { OutboundRow } from './outbounds-tab-types'; import type { OutboundRow } from './outbounds-tab-types';
import CountryPill from './CountryPill'; import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
import TestResultPopover from './TestResultPopover';
import {
effectiveTestMode,
countryFlag,
countryName,
isTesting,
isUntestable,
outboundAddresses,
showSecurity,
testModeLabel,
testResult,
trafficFor,
} from './outbounds-tab-helpers';
interface OutboundColumnsParams { interface OutboundColumnsParams {
testMode: OutboundTestMode;
rows: OutboundRow[]; rows: OutboundRow[];
outboundsTraffic: OutboundTrafficRow[]; outboundsTraffic: OutboundTrafficRow[];
outboundTestStates: Record<number, OutboundTestState>;
openEdit: (idx: number) => void; openEdit: (idx: number) => void;
setFirst: (idx: number) => void; setFirst: (idx: number) => void;
moveUp: (idx: number) => void; moveUp: (idx: number) => void;
moveDown: (idx: number) => void; moveDown: (idx: number) => void;
confirmDelete: (idx: number) => void; confirmDelete: (idx: number) => void;
onResetTraffic: (tag: string) => void; onResetTraffic: (tag: string) => void;
onTest: (index: number, mode: string) => void;
} }
export function useOutboundColumns({ export function useOutboundColumns({
testMode,
rows, rows,
outboundsTraffic, outboundsTraffic,
outboundTestStates,
openEdit, openEdit,
setFirst, setFirst,
moveUp, moveUp,
moveDown, moveDown,
confirmDelete, confirmDelete,
onResetTraffic, onResetTraffic,
onTest,
}: OutboundColumnsParams): ColumnsType<OutboundRow> { }: OutboundColumnsParams): ColumnsType<OutboundRow> {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const [showEgressIp, setShowEgressIp] = useState(false);
return useMemo( return useMemo(
() => [ () => [
{ {
@@ -204,94 +175,6 @@ export function useOutboundColumns({
); );
}, },
}, },
{
title: (
<span className="egress-header">
{t('pages.xray.outbound.egress')}
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showEgressIp ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowEgressIp(false)}
onKeyDown={activateOnKey(() => setShowEgressIp(false))}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowEgressIp(true)}
onKeyDown={activateOnKey(() => setShowEgressIp(true))}
/>
)}
</Tooltip>
</span>
),
key: 'egress',
align: 'left',
width: 210,
render: (_v, record) => {
const egress = testResult(outboundTestStates, record.key)?.egress;
const addresses = [
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
].filter((item): item is { label: string; value: string } => Boolean(item));
if (addresses.length === 0) {
return (
<Tooltip title={t('pages.xray.outbound.egressHint')}>
<span className="empty">—</span>
</Tooltip>
);
}
return (
<div className="egress-stack">
{addresses.map((addr) => (
<Tooltip key={addr.label} title={addr.value}>
<span className="egress-address">
<span className="egress-family">{addr.label}</span>
<span
className={
showEgressIp ? 'address-visible egress-ip' : 'address-hidden egress-ip'
}
>
{addr.value}
</span>
</span>
</Tooltip>
))}
</div>
);
},
},
{
title: t('pages.xray.outbound.country'),
key: 'egressCountry',
align: 'left',
width: 160,
render: (_v, record) => {
const egress = testResult(outboundTestStates, record.key)?.egress;
if (!egress?.country) {
return (
<Tooltip title={t('pages.xray.outbound.egressHint')}>
<span className="empty">—</span>
</Tooltip>
);
}
const flag = countryFlag(egress.country);
const name = countryName(egress.country, i18n.language);
return (
<Tooltip
title={egress.warp ? `Cloudflare trace · WARP ${egress.warp}` : 'Cloudflare trace'}
>
<CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
</Tooltip>
);
},
},
{ {
title: t('pages.inbounds.traffic'), title: t('pages.inbounds.traffic'),
key: 'traffic', key: 'traffic',
@@ -308,45 +191,8 @@ export function useOutboundColumns({
); );
}, },
}, },
{
title: t('pages.nodes.latency'),
key: 'testResult',
align: 'left',
width: 140,
render: (_v, record) => {
const r = testResult(outboundTestStates, record.key);
if (!r)
return isTesting(outboundTestStates, record.key) ? (
<LoadingOutlined />
) : (
<span className="empty">—</span>
);
return <TestResultPopover result={r} />;
},
},
{
title: t('check'),
key: 'test',
align: 'center',
width: 80,
render: (_v, record) => (
<Tooltip
title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}
>
<Button
type="primary"
shape="circle"
loading={isTesting(outboundTestStates, record.key)}
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
icon={<ThunderboltOutlined />}
aria-label={t('check')}
onClick={() => onTest(record.key, testMode)}
/>
</Tooltip>
),
},
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[t, i18n.language, testMode, rows, outboundTestStates, outboundsTraffic, showEgressIp], [t, rows, outboundsTraffic],
); );
} }
@@ -1,165 +0,0 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Col, Input, InputNumber, Row, Select, Space, Tag } from 'antd';
import { AimOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { useInboundOptions } from '@/api/queries/useInboundOptions';
import { buildRemarkByTag, formatInboundTag } from './helpers';
interface RouteTesterProps {
inboundTags: string[];
isMobile: boolean;
}
// Mirror of the /xray/routeTest response (RoutingService.TestRoute).
interface RouteTestResult {
matched: boolean;
outboundTag: string;
groupTags?: string[];
}
const PROTOCOL_OPTIONS = ['http', 'tls', 'quic', 'bittorrent'].map((p) => ({ label: p, value: p }));
export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) {
const { t } = useTranslation();
const { data: inboundOptions } = useInboundOptions();
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
const [dest, setDest] = useState('');
const [port, setPort] = useState<number | null>(443);
const [network, setNetwork] = useState('tcp');
const [inboundTag, setInboundTag] = useState<string | undefined>(undefined);
const [protocol, setProtocol] = useState<string | undefined>(undefined);
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<RouteTestResult | null>(null);
async function run() {
const value = dest.trim();
if (!value) return;
// Domains never contain ':' and a pure dotted-quad is an IPv4 address;
// everything else is treated as a domain.
const isIp = /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(':');
setTesting(true);
setResult(null);
try {
const msg = await HttpUtil.post('/panel/api/xray/routeTest', {
domain: isIp ? '' : value,
ip: isIp ? value : '',
port: port ?? 0,
network,
inboundTag: inboundTag || '',
protocol: protocol || '',
});
if (msg?.success && msg.obj && typeof msg.obj === 'object') {
setResult(msg.obj as RouteTestResult);
}
} finally {
setTesting(false);
}
}
const fieldSpan = isMobile ? 24 : undefined;
return (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Alert type="info" showIcon title={t('pages.xray.routeTesterDesc')} />
<Row gutter={[8, 8]} align="bottom">
<Col xs={fieldSpan} sm={7}>
<Input
aria-label={t('pages.xray.routeTesterDest')}
placeholder={t('pages.xray.routeTesterDest')}
value={dest}
onChange={(e) => setDest(e.target.value)}
onPressEnter={run}
allowClear
/>
</Col>
<Col xs={12} sm={3}>
<InputNumber
aria-label={t('pages.xray.routeTesterPort')}
style={{ width: '100%' }}
min={0}
max={65535}
placeholder={t('pages.xray.routeTesterPort')}
value={port}
onChange={(v) => setPort(v)}
/>
</Col>
<Col xs={12} sm={3}>
<Select
aria-label={t('pages.inbounds.network')}
style={{ width: '100%' }}
value={network}
onChange={setNetwork}
options={[
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
]}
/>
</Col>
<Col xs={12} sm={4}>
<Select
aria-label={t('pages.xray.routeTesterInbound')}
style={{ width: '100%' }}
placeholder={t('pages.xray.routeTesterInbound')}
allowClear
value={inboundTag}
onChange={setInboundTag}
options={inboundTags
.filter(Boolean)
.map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))}
/>
</Col>
<Col xs={12} sm={4}>
<Select
aria-label={t('pages.xray.routeTesterProtocol')}
style={{ width: '100%' }}
placeholder={t('pages.xray.routeTesterProtocol')}
allowClear
value={protocol}
onChange={setProtocol}
options={PROTOCOL_OPTIONS}
/>
</Col>
<Col xs={fieldSpan} sm={3}>
<Button
type="primary"
icon={<AimOutlined />}
loading={testing}
disabled={!dest.trim()}
onClick={run}
block
>
{t('pages.xray.routeTesterTest')}
</Button>
</Col>
</Row>
{result &&
(result.matched ? (
<Alert
type="success"
showIcon
title={
<Space wrap>
<span>{t('pages.xray.routeTesterMatchedOutbound')}:</span>
<Tag color="blue">{result.outboundTag || '—'}</Tag>
{(result.groupTags || []).length > 0 && (
<>
<span>{t('pages.xray.routeTesterViaBalancer')}:</span>
{(result.groupTags || []).map((tag) => (
<Tag key={tag} color="orange">
{tag}
</Tag>
))}
</>
)}
</Space>
}
/>
) : (
<Alert type="warning" showIcon title={t('pages.xray.routeTesterDefaultOutbound')} />
))}
</Space>
);
}
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd'; import { Button, Dropdown, Modal, Space, Table, Tabs, message } from 'antd';
import { import {
AimOutlined,
ControlOutlined, ControlOutlined,
ExportOutlined, ExportOutlined,
ImportOutlined, ImportOutlined,
@@ -16,7 +15,6 @@ import PromptModal from '@/components/feedback/PromptModal';
import TextModal from '@/components/feedback/TextModal'; import TextModal from '@/components/feedback/TextModal';
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback'; import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
import RoutingBasic from './RoutingBasic'; import RoutingBasic from './RoutingBasic';
import RouteTester from './RouteTester';
import RuleFormModal from './RuleFormModal'; import RuleFormModal from './RuleFormModal';
import type { RoutingRule } from './RuleFormModal'; import type { RoutingRule } from './RuleFormModal';
import RuleCardList from './RuleCardList'; import RuleCardList from './RuleCardList';
@@ -430,11 +428,6 @@ export default function RoutingTab({
</Space> </Space>
), ),
}, },
{
key: 'tester',
label: catTabLabel(<AimOutlined />, t('pages.xray.routeTester'), isMobile),
children: <RouteTester inboundTags={inboundTagOptions} isMobile={isMobile} />,
},
]} ]}
/> />
<RuleFormModal <RuleFormModal
-4
View File
@@ -7,12 +7,10 @@ import PanelLayout from '@/layouts/PanelLayout';
const IndexPage = lazy(() => import('@/pages/index/IndexPage')); const IndexPage = lazy(() => import('@/pages/index/IndexPage'));
const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage')); const InboundsPage = lazy(() => import('@/pages/inbounds/InboundsPage'));
const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage')); const ClientsPage = lazy(() => import('@/pages/clients/ClientsPage'));
const GroupsPage = lazy(() => import('@/pages/groups/GroupsPage'));
const NodesPage = lazy(() => import('@/pages/nodes/NodesPage')); const NodesPage = lazy(() => import('@/pages/nodes/NodesPage'));
const HostsPage = lazy(() => import('@/pages/hosts/HostsPage')); const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage')); const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
const XrayPage = lazy(() => import('@/pages/xray/XrayPage')); const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
function withSuspense(node: React.ReactNode) { function withSuspense(node: React.ReactNode) {
return ( return (
@@ -43,14 +41,12 @@ const routes: RouteObject[] = [
{ index: true, element: withSuspense(<IndexPage />) }, { index: true, element: withSuspense(<IndexPage />) },
{ path: 'inbounds', element: withSuspense(<InboundsPage />) }, { path: 'inbounds', element: withSuspense(<InboundsPage />) },
{ path: 'clients', element: withSuspense(<ClientsPage />) }, { path: 'clients', element: withSuspense(<ClientsPage />) },
{ path: 'groups', element: withSuspense(<GroupsPage />) },
{ path: 'nodes', element: withSuspense(<NodesPage />) }, { path: 'nodes', element: withSuspense(<NodesPage />) },
{ path: 'hosts', element: withSuspense(<HostsPage />) }, { path: 'hosts', element: withSuspense(<HostsPage />) },
{ path: 'settings', element: withSuspense(<SettingsPage />) }, { path: 'settings', element: withSuspense(<SettingsPage />) },
{ path: 'xray', element: withSuspense(<XrayPage />) }, { path: 'xray', element: withSuspense(<XrayPage />) },
{ path: 'outbound', element: withSuspense(<XrayPage />) }, { path: 'outbound', element: withSuspense(<XrayPage />) },
{ path: 'routing', element: withSuspense(<XrayPage />) }, { path: 'routing', element: withSuspense(<XrayPage />) },
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
], ],
}, },
]; ];
-23
View File
@@ -259,28 +259,6 @@ export const ActiveInboundsByNodeSchema = z
.nullable() .nullable()
.transform((v) => v ?? {}); .transform((v) => v ?? {});
export const GroupSummarySchema = z.object({
name: z.string(),
clientCount: z.number(),
trafficUsed: z
.number()
.nullable()
.transform((v) => v ?? 0),
up: z
.number()
.nullable()
.transform((v) => v ?? 0),
down: z
.number()
.nullable()
.transform((v) => v ?? 0),
});
export const GroupSummaryListSchema = z
.array(GroupSummarySchema)
.nullable()
.transform((v) => v ?? []);
export function hasForbiddenClientChars(value: string): boolean { export function hasForbiddenClientChars(value: string): boolean {
if (value.includes('/') || value.includes('\\') || value.includes(' ')) return true; if (value.includes('/') || value.includes('\\') || value.includes(' ')) return true;
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
@@ -373,4 +351,3 @@ export type BulkDetachResult = z.infer<typeof BulkDetachResultSchema>;
export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>; export type ClientBulkAddFormValues = z.infer<typeof ClientBulkAddFormSchema>;
export type ClientBulkAdjustFormValues = z.infer<typeof ClientBulkAdjustFormSchema>; export type ClientBulkAdjustFormValues = z.infer<typeof ClientBulkAdjustFormSchema>;
export type ClientFormValues = z.infer<typeof ClientFormSchema>; export type ClientFormValues = z.infer<typeof ClientFormSchema>;
export type GroupSummary = z.infer<typeof GroupSummarySchema>;
@@ -14,6 +14,22 @@ export type TrafficReset = z.infer<typeof TrafficResetSchema>;
export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']); export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']);
export type ShareAddrStrategy = z.infer<typeof ShareAddrStrategySchema>; export type ShareAddrStrategy = z.infer<typeof ShareAddrStrategySchema>;
// Per-inbound forwarding form (the panel-side 「转发」 section). Persisted on
// the inbound row as a JSON string; the backend compiles it into the entry
// agent's egress at config-pull time. mode 'direct' (or an absent spec)
// means no forwarding.
export const ForwardingSpecSchema = z.object({
mode: z.enum(['direct', 'forward']).default('direct'),
landing: z.enum(['managed', 'manual']).default('managed'),
targetInboundId: z.number().int().default(0),
address: z.string().default(''),
port: z.number().int().min(0).max(65535).default(0),
protocol: z.enum(['socks5', 'http']).default('socks5'),
username: z.string().default(''),
password: z.string().default(''),
});
export type ForwardingSpec = z.infer<typeof ForwardingSpecSchema>;
// Db-side fields layered on top of the xray slice. These mirror the // Db-side fields layered on top of the xray slice. These mirror the
// DBInbound model — they live in the SQL row, not in xray's config. // DBInbound model — they live in the SQL row, not in xray's config.
export const InboundDbFieldsSchema = z.object({ export const InboundDbFieldsSchema = z.object({
@@ -24,6 +40,16 @@ export const InboundDbFieldsSchema = z.object({
trafficResetDay: z.number().int().min(1).max(31).default(1), trafficResetDay: z.number().int().min(1).max(31).default(1),
lastTrafficResetTime: z.number().int().default(0), lastTrafficResetTime: z.number().int().default(0),
nodeId: z.number().int().nullable().optional(), nodeId: z.number().int().nullable().optional(),
forwarding: ForwardingSpecSchema.default({
mode: 'direct',
landing: 'managed',
targetInboundId: 0,
address: '',
port: 0,
protocol: 'socks5',
username: '',
password: '',
}),
shareAddrStrategy: ShareAddrStrategySchema.default('node'), shareAddrStrategy: ShareAddrStrategySchema.default('node'),
shareAddr: z.string().default(''), shareAddr: z.string().default(''),
subSortIndex: z.number().int().min(1).default(1), subSortIndex: z.number().int().min(1).default(1),
+41 -2
View File
@@ -25,6 +25,12 @@ export const NodeRecordSchema = z
activeCount: z.number().optional(), activeCount: z.number().optional(),
disabledCount: z.number().optional(), disabledCount: z.number().optional(),
depletedCount: z.number().optional(), depletedCount: z.number().optional(),
// Per-category client email lists behind the counts above; the nodes
// page category chips pop these over on hover.
onlineEmails: z.array(z.string()).optional(),
activeEmails: z.array(z.string()).optional(),
disabledEmails: z.array(z.string()).optional(),
depletedEmails: z.array(z.string()).optional(),
lastHeartbeat: z.number().optional(), lastHeartbeat: z.number().optional(),
lastError: z.string().optional(), lastError: z.string().optional(),
// Xray state captured from the remote node's own /panel/api/server/status. // Xray state captured from the remote node's own /panel/api/server/status.
@@ -44,6 +50,17 @@ export const NodeRecordSchema = z
guid: z.string().optional(), guid: z.string().optional(),
parentGuid: z.string().optional(), parentGuid: z.string().optional(),
transitive: z.boolean().optional(), transitive: z.boolean().optional(),
// P7: node kind, annotated by the backend for nodes registered by an
// agent. Absent/traditional for classic panel-to-panel nodes.
nodeType: z.enum(['traditional', 'agent']).optional(),
// P13: host IPv4/IPv6 an agent reports with its heartbeat telemetry.
// Rendered as plain text in the address column for agent rows.
ipv4: z.string().optional(),
ipv6: z.string().optional(),
// Item 20: last one-click agent update outcome, agent rows only.
agentUpdateState: z.string().optional(),
agentUpdateError: z.string().optional(),
agentUpdateTarget: z.string().optional(),
}) })
.loose(); .loose();
@@ -66,12 +83,19 @@ export const NodeFormSchema = z
id: z.number().optional(), id: z.number().optional(),
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'), name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
remark: z.string().optional(), remark: z.string().optional(),
// Node type: traditional panel-to-panel, or lightweight agent.
// Agent nodes skip the panel probe and API token; an agent token is
// issued on creation instead.
nodeType: z.enum(['traditional', 'agent']).optional().default('traditional'),
scheme: z.enum(['http', 'https']), scheme: z.enum(['http', 'https']),
address: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'), // For agent nodes the panel never dials the node (agent dials out),
port: z.number().int().min(1).max(65535), // so address/port are not needed. Required for traditional nodes.
address: z.string().trim().optional().default(''),
port: z.number().int().min(1).max(65535).optional(),
basePath: z.string(), basePath: z.string(),
// mTLS nodes authenticate via the client certificate, so the token is optional // mTLS nodes authenticate via the client certificate, so the token is optional
// there; every other verify mode still requires one (matches remote.do()). // there; every other verify mode still requires one (matches remote.do()).
// Agent nodes use an agent token instead of a panel API token.
apiToken: z.string().trim(), apiToken: z.string().trim(),
hasStoredToken: z.boolean().optional().default(false), hasStoredToken: z.boolean().optional().default(false),
enable: z.boolean(), enable: z.boolean(),
@@ -88,6 +112,21 @@ export const NodeFormSchema = z
outboundTag: z.string().optional(), outboundTag: z.string().optional(),
}) })
.superRefine((val, ctx) => { .superRefine((val, ctx) => {
if (val.nodeType === 'agent') return; // no panel credentials needed
if (!val.address || val.address.trim().length === 0) {
ctx.addIssue({
code: 'custom',
path: ['address'],
message: 'pages.nodes.toasts.fillRequired',
});
}
if (val.port === undefined) {
ctx.addIssue({
code: 'custom',
path: ['port'],
message: 'pages.nodes.toasts.fillRequired',
});
}
if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0 && !val.hasStoredToken) { if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0 && !val.hasStoredToken) {
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: 'custom',

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