Compare commits
89
Commits
e678e7716b
..
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d2a3afff0 | ||
|
|
ea0efd4468 | ||
|
|
0f0ac3c4b7 | ||
|
|
23aa28fbea | ||
|
|
56546b20c5 | ||
|
|
b1b251b897 | ||
|
|
d6942c5702 | ||
|
|
b28fc572f7 | ||
|
|
5c924db235 | ||
|
|
fbb2a824dc | ||
|
|
944da9fee4 | ||
|
|
27ebdac034 | ||
|
|
9c302b2a33 | ||
|
|
57690a159c | ||
|
|
f57eb55328 | ||
|
|
129a76696b | ||
|
|
a40bd6a01b | ||
|
|
48e97bd91f | ||
|
|
955fa340af | ||
|
|
c3c0f56e56 | ||
|
|
d1214d4cbd | ||
|
|
8d4579f987 | ||
|
|
6ac0a63f28 | ||
|
|
14d26bec71 | ||
|
|
91243354de | ||
|
|
25d60f9718 | ||
|
|
158dc9fca7 | ||
|
|
275f536429 | ||
|
|
b276c4a8fe | ||
|
|
f761d13579 | ||
|
|
2580c03bfa | ||
|
|
8eb98e2e9a | ||
|
|
013f537eaa | ||
|
|
7d08f37248 | ||
|
|
4c43756208 | ||
|
|
f927f9fff7 | ||
|
|
60fb015e36 | ||
|
|
07a43b5a56 | ||
|
|
eec5b791c8 | ||
|
|
ce181e9ae5 | ||
|
|
5dede01c3e | ||
|
|
35eb463633 | ||
|
|
bd087aabea | ||
|
|
5bd4e8d609 | ||
|
|
8026c432e7 | ||
|
|
5c39f666dc | ||
|
|
76460c7074 | ||
|
|
5b6ea8469f | ||
|
|
fdf3bfb659 | ||
|
|
dd100cfc97 | ||
|
|
9e87517a4e | ||
|
|
0eef680fba | ||
|
|
0eb8d05ed8 | ||
|
|
c9a0b87af4 | ||
|
|
97c3943403 | ||
|
|
fb1dd401b2 | ||
|
|
6c8a1c4e0e | ||
|
|
d028e63a74 | ||
|
|
4e504e9003 | ||
|
|
58d317afce | ||
|
|
272a0931a1 | ||
|
|
8bc97eb47a | ||
|
|
d41012a0ab | ||
|
|
0ddefd0d5d | ||
|
|
5a04968e17 | ||
|
|
de3a4c9bcd | ||
|
|
a44f410074 | ||
|
|
4da501ded6 | ||
|
|
a4d08892f7 | ||
|
|
6bedce215c | ||
|
|
17f8b3f3fa | ||
|
|
49038ce5f9 | ||
|
|
e9e2eb555d | ||
|
|
afe53b9790 | ||
|
|
cb8effe05b | ||
|
|
08ab73a4f1 | ||
|
|
43d410d02c | ||
|
|
5049879a70 | ||
|
|
c5ac59ecf9 | ||
|
|
6b208847a2 | ||
|
|
007d137b5d | ||
|
|
6029eacf50 | ||
|
|
860da5d28f | ||
|
|
71adcd60ef | ||
|
|
e8b8181ef6 | ||
|
|
5cdc183190 | ||
|
|
0062a261f7 | ||
|
|
f97981b146 | ||
|
|
cf24755375 |
@@ -10,6 +10,10 @@ PATCHBAY_CONFIG=./dev/home/config.yaml
|
||||
PATCHBAY_MIHOMO_BIN=../binary/mihomo-windows-amd64-v3-go125.exe
|
||||
PATCHBAY_MIHOMO_HOME=./dev/home
|
||||
|
||||
# 本地测试内核用的订阅地址,bun run dev:reset 会把它填进 dev/home/config.yaml。
|
||||
# 开发时一般是 SSH 隧道映射过来的 Sub-Store:ssh -N -L 3001:127.0.0.1:3001 用户名@服务器
|
||||
DEV_SUBSCRIPTION_URL=
|
||||
|
||||
# ── 想对着真实机器调试时改成这一套 ──────────────────────
|
||||
# 先开隧道把管理接口映射到本机:
|
||||
# ssh -N -f -L 9090:127.0.0.1:9090 用户名@服务器地址
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# 所有文本文件统一 LF:并行 worktree 曾把 fixture 检出成 CRLF,逐字节比较的测试全挂
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,57 @@
|
||||
# 发布流程:推送 v* 标签 → 测试 → 构建 → 把产物挂到 Gitea Release 上。
|
||||
#
|
||||
# 产物 patchbay.tar.gz 与 CPU 架构无关(纯 JS + 静态文件),
|
||||
# 架构差异由目标机器上的 bun 运行时吸收,安装侧见仓库根目录的 install.sh。
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# 构建用的 bun 版本和 install.sh 里的 BUN_VERSION 保持一致,升级时两处一起改
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.14'
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun test
|
||||
- run: bun run typecheck
|
||||
|
||||
# 布局必须保持 server/ 与 web-dist/ 平级:
|
||||
# server/routes.ts 里 WEB_DIST 用 import.meta.url 相对定位 ../web-dist/
|
||||
- name: 构建并打包
|
||||
run: |
|
||||
bun run build
|
||||
bun build --target=bun --minify server/index.ts --outfile dist/patchbay/server/index.js
|
||||
cp -r web-dist dist/patchbay/web-dist
|
||||
echo "${GITHUB_REF_NAME}" > dist/patchbay/version.txt
|
||||
tar -czf patchbay.tar.gz -C dist patchbay
|
||||
|
||||
# 直接 curl Gitea 的 API 而不用现成 action:gitea.com/actions/release-action
|
||||
# 是 Go 程序,runner 镜像里没有 Go 工具链,跑不起来
|
||||
- name: 发布 Release
|
||||
env:
|
||||
# 默认令牌建不了 Release 时(老版本 Gitea 的权限限制),
|
||||
# 在仓库 Settings → Actions → Secrets 里加一个 RELEASE_TOKEN
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN || github.token }}
|
||||
run: |
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
auth="Authorization: token ${TOKEN}"
|
||||
# 建 Release;已存在(同一个 tag 重跑)就查回它的 id
|
||||
resp="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \
|
||||
"$api/releases" \
|
||||
|| curl -fsS -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}")"
|
||||
# 响应里第一个 "id" 就是 release 自己的(嵌套对象如 author 排在它后面)
|
||||
id="$(printf '%s' "$resp" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)"
|
||||
[ -n "$id" ] || { echo "没拿到 release id,响应: $resp"; exit 1; }
|
||||
curl -fsS -X POST -H "$auth" -F "attachment=@patchbay.tar.gz" \
|
||||
"$api/releases/${id}/assets?name=patchbay.tar.gz" >/dev/null
|
||||
echo "已上传 patchbay.tar.gz"
|
||||
+30
-10
@@ -10,9 +10,9 @@
|
||||
mihomo 上的一个监听端口,客户端连上它把流量交进来。
|
||||
_Avoid_: inbound、监听器、listener、进站
|
||||
|
||||
**默认入站**:
|
||||
由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除。
|
||||
_Avoid_: 主端口、老式端口、默认端口
|
||||
**legacy 入站**:
|
||||
由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除,可升级为命名入站。
|
||||
_Avoid_: 默认入站、主端口、老式端口、默认端口
|
||||
|
||||
**命名入站**:
|
||||
由 `listeners:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。
|
||||
@@ -52,9 +52,25 @@ _Avoid_: provider、机场、订阅链接、节点池
|
||||
一组线路加一个挑选方式(手动选、自动测速选最快等)。策略组本身也可以作为出站被引用。
|
||||
_Avoid_: 分组、group、代理组、策略
|
||||
|
||||
**筛选组**:
|
||||
Patchbay 自动创建的策略组,用筛选条件从订阅里只挑出用户指定的那几条线路。它的存在是为了让入站能固定到具体线路——因为订阅线路无法被直接引用。
|
||||
_Avoid_: 隐藏组、临时组、影子组
|
||||
**专属组**:
|
||||
Patchbay 为一路入站自动创建的三个策略组:手动选的 SELECT、自动测速的 AUTO、故障转移的 FALLBACK。入站的 `proxy:` 永久指向 SELECT,日常换线是改 SELECT 的当前选中,属于运行时动作,不改配置文件。名字带 `patchbay:` 前缀。
|
||||
_Avoid_: 筛选组、隐藏组、影子组、自动组
|
||||
|
||||
**链式代理**:
|
||||
让一路入站的流量先经过一跳入口,再从出口线路离开。在订阅副本上用 `override.dialer-proxy` 实现,因为内核不接受写在策略组上的 `dialer-proxy`。
|
||||
_Avoid_: 中转、前置代理、relay
|
||||
|
||||
**入口组**:
|
||||
链式代理的入口,一个手动选的策略组,订阅副本的 `dialer-proxy` 指向它。它只能引用原订阅,不能引用订阅副本,否则会形成内核查不出来的环。
|
||||
_Avoid_: 前置组、跳板组
|
||||
|
||||
**订阅副本**:
|
||||
开链式代理时,Patchbay 按「入站乘以来源订阅」的组合生成的订阅。`type: file`,读原订阅的缓存文件,带上指向入口组的 `dialer-proxy`。它不联网,原订阅刷新后内核的文件监视让它自动跟上。和哪个用户订阅共用同一个缓存文件,来源就是哪个。
|
||||
_Avoid_: 派生订阅、克隆订阅、镜像订阅
|
||||
|
||||
**公网 IP**:
|
||||
一路入站的流量离开本机后,在外部服务眼里来自哪个地址。由 Patchbay 服务端把本机对应端口设为代理、访问 ip.sb 得到。它是观测结果,不是配置里的任何字段。
|
||||
_Avoid_: 出口 IP、落地 IP、出口
|
||||
|
||||
## 名字与引用
|
||||
|
||||
@@ -69,9 +85,13 @@ _Avoid_: 无效引用、悬空引用、坏引用
|
||||
## 规则
|
||||
|
||||
**分流规则**:
|
||||
配置文件 `rules:` 里的有序列表,自上而下匹配,决定一条连接走哪个出站。顺序本身是语义的一部分。
|
||||
自上而下匹配、决定一条连接走哪个出站的有序规则列表,第一条命中即生效。顺序本身是语义的一部分。全局规则和附加规则都是分流规则。
|
||||
_Avoid_: 路由规则、规则表
|
||||
|
||||
**全局规则**:
|
||||
配置文件顶层 `rules:` 里的分流规则表。只对没有附加规则的入站生效——带附加规则的入站整表改查自己的表,不再看它。
|
||||
_Avoid_: 顶层规则、主规则、默认规则
|
||||
|
||||
**附加规则**:
|
||||
挂在某一路入站上的一组分流规则,只对从该入站进来的流量生效。
|
||||
_Avoid_: sub-rules、子规则、私有规则
|
||||
@@ -82,6 +102,6 @@ _Avoid_: sub-rules、子规则、私有规则
|
||||
Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。
|
||||
_Avoid_: 托管段落、受控段落
|
||||
|
||||
**默认路由**:
|
||||
默认入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
|
||||
_Avoid_: 主路由、根路由
|
||||
**legacy 路由**:
|
||||
legacy 入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
|
||||
_Avoid_: 默认路由、主路由、根路由
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
一个跑在服务器上的网页,用来编辑那台机器上 mihomo 的 `config.yaml` 并让改动生效。
|
||||
|
||||
核心动作是把某一路入站接到某一个出站上——名字取自录音棚的插线板。典型用途是给每个容器开一个端口,各自指定走哪条线路。
|
||||
核心动作是把某一路入站接到某一个出站上,名字取自录音棚的插线板。典型用途是给每个容器开一个端口,各自指定走哪条线路。
|
||||
|
||||
聚合订阅不在范围内(那是 Sub-Store 的活),安装升级内核也不在(那是 `mihomo-up.sh` 的活)。
|
||||
|
||||
术语以 [CONTEXT.md](./CONTEXT.md) 为准。为什么这么设计记在仓库外面的 `docs/adr/`。
|
||||
聚合订阅不在范围内(那是 Sub-Store 的活),安装升级内核也不在(那是 `mihomo-up.sh` 的活)。术语以 [CONTEXT.md](./CONTEXT.md) 为准。
|
||||
|
||||
## 跑起来
|
||||
|
||||
@@ -18,43 +16,36 @@ cp .env.example .env # 按里面的注释填
|
||||
bun run start # 打包前端并启动,默认 http://127.0.0.1:7580
|
||||
```
|
||||
|
||||
首次启动会生成一个访问令牌,打印在控制台上,同时存到配置文件旁边的 `.patchbay-token`。
|
||||
首次启动会生成访问令牌,打印在控制台上,也存在配置文件旁边的 `.patchbay-token`。
|
||||
|
||||
**内核不在线也能用**,只是看不到订阅里有哪些线路、也没法让改动立刻生效。这是有意的——配置写坏导致内核起不来的时候,正是最需要这个界面的时候。
|
||||
内核不在线也能用,只是看不到订阅里有哪些线路,改动也要等内核启动才生效。这是有意的:配置写坏导致内核起不来的时候,正是最需要这个界面的时候。
|
||||
|
||||
## 开发
|
||||
|
||||
改前端时用 Vite,它会把接口请求转发给后端:
|
||||
|
||||
```bash
|
||||
bun run dev # 后端,带热重载
|
||||
bun run dev:web # 前端,http://localhost:5173
|
||||
bun run dev:kernel # 终端 1:本地测试内核,开着不用管
|
||||
bun run dev:all # 终端 2:后端 + 前端,改了自动生效
|
||||
```
|
||||
|
||||
本地有一份测试用的内核配置(`dev/config.yaml`,全是占位地址,端口刻意避开 mihomo 的默认值),可以对着它跑完整的「写回 → 校验 → 重载 → 失败还原」:
|
||||
|
||||
```bash
|
||||
bun run dev:kernel # 启动本地测试内核,管理接口 19090
|
||||
bun run dev:reset # 把测试配置恢复成模板的样子
|
||||
```
|
||||
然后访问 http://localhost:5173(不是 7580)。第一次先运行 `bun run dev:reset` 生成测试配置:模板在 `dev/config.yaml`,订阅地址从 `.env` 取。
|
||||
|
||||
## 检查
|
||||
|
||||
```bash
|
||||
bun test # 单元测试,不需要内核
|
||||
bun run smoke # 接口冒烟测试,需要内核和后端都在跑
|
||||
bun test # 单元测试,不需要内核
|
||||
bun run smoke # 接口冒烟测试,需要内核和后端都在跑
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
另有两个一次性脚本:`scripts/roundtrip.ts` 验证改一处配置之后 diff 里只出现那一处,`scripts/safety-net.ts` 验证配置写坏时能不能把用户救回来。
|
||||
`scripts/` 里另有几个手动验证脚本(写回保真、落盘保护链路、链式代理的生成产物),用法写在各文件头部。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
server/ 后端。config-store.ts 是核心,那条保护链路在里面
|
||||
web/ 前端。styles/tokens.css 顶部写着设计基调的三条规则
|
||||
server/ 后端。config-store.ts 是核心,落盘保护链路在里面
|
||||
web/ 前端。styles/tokens.css 顶部写着设计基调
|
||||
shared/ 两边共用的领域逻辑,纯函数,带测试
|
||||
vendor/ 从别的项目复制来的代码,来源和本地改动记在各自的 ORIGIN.md
|
||||
scripts/ 验证用的脚本,不参与打包
|
||||
scripts/ 验证脚本,不参与打包
|
||||
dev/ 本地测试内核的配置
|
||||
```
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"": {
|
||||
"name": "patchbay",
|
||||
"dependencies": {
|
||||
"@tanstack/vue-virtual": "^3.13.35",
|
||||
"echarts": "^6.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"yaml": "^2.9.0",
|
||||
},
|
||||
@@ -153,6 +155,10 @@
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.7", "", {}, "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA=="],
|
||||
|
||||
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.35", "", { "dependencies": { "@tanstack/virtual-core": "3.17.7" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-lOfSPvgPdlaH6Qy+CyIc3XpycitaSQ9GECndGpTuDiu+uDA1am+90yWXwzDSd/20ZM196ggWJLS+Qb6WjVd/OA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
@@ -197,6 +203,8 @@
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
@@ -247,6 +255,8 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
@@ -269,6 +279,8 @@
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"zrender": ["zrender@6.1.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="],
|
||||
|
||||
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
+23
-16
@@ -1,7 +1,12 @@
|
||||
# 本地开发用的测试配置 — 不含任何真实信息
|
||||
# 端口刻意避开默认值(7890 / 9090),免得和机器上其它 mihomo 打架。
|
||||
# 里面的服务器地址全是占位的,从不会真的连上去;这份配置只用来验证
|
||||
# 「写回 → 校验 → 重载 → 失败还原」这条链路,不用来真的代理流量。
|
||||
# 本地开发用的测试配置模板。
|
||||
#
|
||||
# 这是模板,不是实际运行的文件:`bun run dev:reset` 会把 __SUBSCRIPTION_URL__ 换成
|
||||
# .env 里的 DEV_SUBSCRIPTION_URL,生成 dev/home/config.yaml(那个文件不进 git)。
|
||||
# 订阅地址里通常带访问令牌,不能提交。
|
||||
#
|
||||
# 端口刻意避开 mihomo 的默认值(7890 / 9090),免得和机器上其它内核打架。
|
||||
# 结构照着真实那台机器来:没有手写线路,全部通过订阅引入——这样开发时遇到的
|
||||
# 约束和真实环境一致(比如订阅线路无法被入站直接引用)。
|
||||
|
||||
mixed-port: 17890 # 代理端口,避开默认的 7890
|
||||
allow-lan: false # 本地测试不对局域网开放
|
||||
@@ -12,28 +17,30 @@ ipv6: false
|
||||
external-controller: 127.0.0.1:19090 # 管理接口,避开默认的 9090
|
||||
secret: "dev-secret-local-only"
|
||||
|
||||
# 关掉自动更新,避免测试时下载 GEO 数据库
|
||||
geo-auto-update: false
|
||||
geo-auto-update: false # 关掉自动更新,避免测试时下载 GEO 数据库
|
||||
|
||||
proxies:
|
||||
- name: 占位线路甲
|
||||
type: socks5
|
||||
server: 127.0.0.1
|
||||
port: 1080
|
||||
- name: 占位线路乙
|
||||
type: socks5
|
||||
server: 127.0.0.1
|
||||
port: 1081
|
||||
proxy-providers:
|
||||
subs:
|
||||
type: http
|
||||
url: "__SUBSCRIPTION_URL__"
|
||||
path: ./providers/subs.yaml
|
||||
interval: 86400
|
||||
health-check:
|
||||
enable: true
|
||||
url: https://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
|
||||
proxy-groups:
|
||||
- name: PROXY # 总开关,手动选
|
||||
type: select
|
||||
proxies: [AUTO, DIRECT]
|
||||
use: [subs]
|
||||
- name: AUTO # 自动挑延迟最低的
|
||||
type: url-test
|
||||
proxies: [占位线路甲, 占位线路乙]
|
||||
use: [subs]
|
||||
url: https://www.gstatic.com/generate_204
|
||||
interval: 300
|
||||
tolerance: 50
|
||||
|
||||
rules:
|
||||
# 本机和内网地址一律直连
|
||||
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# install.sh — Patchbay 的安装 / 更新 / 卸载脚本
|
||||
#
|
||||
# 它管四样东西:
|
||||
# 1. bun 运行时(私有副本,版本钉死) 默认 /opt/patchbay/bun/bun
|
||||
# 2. 应用本体(来自 Gitea Release) 默认 /opt/patchbay/app/
|
||||
# 3. 环境变量文件 /etc/patchbay/env
|
||||
# 4. systemd 服务 /etc/systemd/system/patchbay.service
|
||||
#
|
||||
# bash <(curl -fsSL https://git.im0o.top/im0o/mihomo-patchbay/raw/branch/main/install.sh)
|
||||
# └─ 安装;已装过就更新到最新 Release(环境变量文件保持不动)
|
||||
# install.sh check # 只查询有没有新版,不改动任何东西,退出码见 --help
|
||||
# install.sh uninstall # 卸载;--purge 连环境变量文件和访问令牌一起删除
|
||||
#
|
||||
# 前提:机器上已经用 mihomo-up.sh 装好了 mihomo(Patchbay 要读它的配置文件、
|
||||
# 调它的二进制做校验、连它的管理接口)。还没装先去装那个。
|
||||
#
|
||||
set -Eeuo pipefail
|
||||
|
||||
SELF_VERSION="1.0.0"
|
||||
|
||||
REPO_URL="https://git.im0o.top/im0o/mihomo-patchbay"
|
||||
API_URL="https://git.im0o.top/api/v1/repos/im0o/mihomo-patchbay"
|
||||
# bun 版本钉死,避免运行时行为漂移;和 .gitea/workflows/release.yml 的构建版本一起改
|
||||
BUN_VERSION="${PATCHBAY_BUN_VERSION:-1.3.14}"
|
||||
|
||||
PREFIX="${PATCHBAY_PREFIX:-/opt/patchbay}"
|
||||
ENV_FILE="/etc/patchbay/env"
|
||||
SERVICE="${PATCHBAY_SERVICE:-patchbay}"
|
||||
UNIT_FILE="/etc/systemd/system/${SERVICE}.service"
|
||||
|
||||
# mihomo-up.sh 装出来的默认布局;mihomo 装在别处时用这两个环境变量覆盖
|
||||
MIHOMO_BIN="${MIHOMO_BIN:-/usr/local/bin/mihomo}"
|
||||
MIHOMO_CONF_DIR="${MIHOMO_CONF_DIR:-/etc/mihomo}"
|
||||
|
||||
# GitHub 下载前缀(bun 从 GitHub 下载)。直连不通时填个反代,例如 https://ghfast.top/
|
||||
MIRROR="${MIHOMO_MIRROR:-}"
|
||||
|
||||
WANT_VERSION=""; FORCE=0; ASSUME_YES=0; PURGE=0
|
||||
TMPDIR_=""
|
||||
|
||||
# ───────────────────────── 输出 ─────────────────────────
|
||||
if [[ -t 1 ]]; then
|
||||
R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; W=$'\033[1m'; D=$'\033[2m'; N=$'\033[0m'
|
||||
else
|
||||
R=""; G=""; Y=""; W=""; D=""; N=""
|
||||
fi
|
||||
|
||||
log() { printf '%s\n' "$*" >&2; }
|
||||
info() { log "${D}··${N} $*"; }
|
||||
ok() { log "${G}✓${N} $*"; }
|
||||
warn() { log "${Y}!${N} $*"; }
|
||||
die() { printf '%s✗%s %s\n' "$R" "$N" "$*" >&2; exit 1; }
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
trap - ERR
|
||||
[[ -n "${TMPDIR_:-}" && -d "$TMPDIR_" ]] && rm -rf "$TMPDIR_"
|
||||
return $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'die "第 $LINENO 行出错,已中止"' ERR
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
install.sh — 安装 / 更新 / 卸载 Patchbay
|
||||
|
||||
install.sh 安装;已装过就更新到最新 Release
|
||||
install.sh check 只查询有没有新版,不改动任何东西
|
||||
install.sh uninstall [--purge]
|
||||
|
||||
选项:
|
||||
-y, --yes 非交互确认,所有询问都当作同意(用默认值)
|
||||
--force 版本相同也重新安装
|
||||
--version <v> 装指定版本的 Release,如 v0.1.0;也用于回滚
|
||||
--mirror <url> GitHub 下载前缀(下载 bun 用),如 https://ghfast.top/
|
||||
--purge uninstall 时连环境变量文件和访问令牌一起删除
|
||||
-v 打印本脚本自身的版本号
|
||||
|
||||
check 退出码: 0 = 已是最新 10 = 有新版或未安装 1 = 出错
|
||||
EOF
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
# 从 /dev/tty 读,这样 bash <(curl ...) 这种 stdin 被占用的场景也能正常按键
|
||||
TTY_IN="/dev/stdin"
|
||||
[[ -r /dev/tty ]] && TTY_IN="/dev/tty"
|
||||
|
||||
ask_default() { # ask_default "提示" "默认值" varname
|
||||
local __adf_def="$2" __adf_var="$3" __adf_ans=""
|
||||
if (( ASSUME_YES )); then printf -v "$__adf_var" '%s' "$__adf_def"; return 0; fi
|
||||
printf '%s %s[%s]%s ' "$1" "$D" "$__adf_def" "$N" >&2
|
||||
read -r __adf_ans < "$TTY_IN" || __adf_ans=""
|
||||
[[ -z "$__adf_ans" ]] && __adf_ans="$__adf_def"
|
||||
printf -v "$__adf_var" '%s' "$__adf_ans"
|
||||
}
|
||||
|
||||
# ───────────────────────── 基础能力 ─────────────────────────
|
||||
need() { command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1"; }
|
||||
|
||||
require_root() {
|
||||
[[ ${EUID:-$(id -u)} -eq 0 ]] || die "$1 需要 root 权限,请用 sudo 重新运行"
|
||||
}
|
||||
|
||||
gh_url() { # gh_url "owner/repo/releases/..." -> 完整下载地址
|
||||
local u="https://github.com/$1"
|
||||
[[ -n "$MIRROR" ]] && u="${MIRROR%/}/$u"
|
||||
printf '%s' "$u"
|
||||
}
|
||||
|
||||
# 这台 Gitea 的网页路由没有 /releases/latest/download/<asset> 这种别名(实测 404),
|
||||
# 所以下载地址必须带具体版本号,「最新」由 latest_version 先问 API
|
||||
release_url() { # release_url <版本> <asset> -> 下载地址
|
||||
printf '%s/releases/download/%s/%s' "$REPO_URL" "$1" "$2"
|
||||
}
|
||||
|
||||
# 读 config.yaml 顶层的简单键(形如 `secret: "xxx"`),套路取自 mihomo-up.sh 的 cfg_get
|
||||
conf_get() {
|
||||
local key="$1" f="$MIHOMO_CONF_DIR/config.yaml"
|
||||
[[ -f "$f" ]] || return 0
|
||||
sed -n -E "s/^${key}:[[:space:]]*//p" "$f" | sed -n '1p' \
|
||||
| sed -E 's/[[:space:]]*#.*$//; s/^"(.*)"$/\1/; s/^'"'"'(.*)'"'"'$/\1/'
|
||||
}
|
||||
|
||||
installed_version() {
|
||||
[[ -f "$PREFIX/app/version.txt" ]] && tr -d '[:space:]' < "$PREFIX/app/version.txt" || printf ''
|
||||
}
|
||||
|
||||
latest_version() {
|
||||
local resp v
|
||||
resp="$(curl -fsSL --retry 2 --connect-timeout 5 --max-time 15 "$API_URL/releases/latest")" \
|
||||
|| die "查询最新 Release 失败: $API_URL/releases/latest(还没发布过 Release?网络不通?)"
|
||||
# 响应里 tag_name 只出现一次(release 对象自己的),不装 jq 也能取
|
||||
v="$(printf '%s' "$resp" | grep -o '"tag_name":"[^"]*"' | head -n1 | cut -d'"' -f4)"
|
||||
[[ "$v" =~ ^v[0-9]+\.[0-9]+ ]] || die "拿到的版本号格式不对: ${v:-空}"
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
svc_running() { systemctl is-active --quiet "$SERVICE" 2>/dev/null; }
|
||||
|
||||
# ───────────────────────── bun 运行时 ─────────────────────────
|
||||
bun_asset() {
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64)
|
||||
# bun 的常规 x64 构建要求 CPU 支持 AVX2,老机器要用 baseline 构建
|
||||
if grep -qw avx2 /proc/cpuinfo 2>/dev/null; then echo "bun-linux-x64"
|
||||
else echo "bun-linux-x64-baseline"; fi ;;
|
||||
aarch64|arm64) echo "bun-linux-aarch64" ;;
|
||||
*) die "未适配的架构: $(uname -m)(bun 只发布 linux x64 / aarch64)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
install_bun() {
|
||||
local bun="$PREFIX/bun/bun"
|
||||
if [[ -x "$bun" && "$("$bun" --version 2>/dev/null || true)" == "$BUN_VERSION" ]]; then
|
||||
info "bun $BUN_VERSION 已就位,跳过"
|
||||
return 0
|
||||
fi
|
||||
need unzip
|
||||
local asset url
|
||||
asset="$(bun_asset)"
|
||||
url="$(gh_url "oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${asset}.zip")"
|
||||
|
||||
info "下载 ${asset}.zip(约 30 MB)"
|
||||
curl -fL -# --retry 3 --connect-timeout 15 --max-time 600 -o "$TMPDIR_/bun.zip" "$url" \
|
||||
|| die "下载失败: $url(GitHub 不通就加 --mirror 换个下载源)"
|
||||
unzip -q -o "$TMPDIR_/bun.zip" -d "$TMPDIR_" || die "解压失败,下载到的不是有效的 zip"
|
||||
|
||||
# 闸门: 新 bun 能运行起来,且自报版本号和目标一致(也能暴露架构选错)
|
||||
local got
|
||||
got="$("$TMPDIR_/$asset/bun" --version 2>/dev/null || true)"
|
||||
[[ "$got" == "$BUN_VERSION" ]] \
|
||||
|| die "bun 校验失败: 期望 $BUN_VERSION,实际 ${got:-执行不了}(多半是架构不对)"
|
||||
|
||||
mkdir -p "$PREFIX/bun"
|
||||
install -m 0755 "$TMPDIR_/$asset/bun" "$bun"
|
||||
ok "bun $BUN_VERSION → $bun"
|
||||
}
|
||||
|
||||
# ───────────────────────── 应用本体 ─────────────────────────
|
||||
install_app() { # $1 = 目标版本
|
||||
local target="$1" url
|
||||
url="$(release_url "$target" patchbay.tar.gz)"
|
||||
|
||||
info "下载 patchbay.tar.gz"
|
||||
curl -fL -# --retry 3 --connect-timeout 15 --max-time 300 -o "$TMPDIR_/app.tar.gz" "$url" \
|
||||
|| die "下载失败: $url"
|
||||
mkdir -p "$TMPDIR_/unpack"
|
||||
tar -xzf "$TMPDIR_/app.tar.gz" -C "$TMPDIR_/unpack" || die "解压失败,下载到的不是有效的 tar.gz"
|
||||
|
||||
# 闸门: 包里的东西齐不齐。server/ 和 web-dist/ 必须平级,服务端靠相对路径找前端
|
||||
[[ -f "$TMPDIR_/unpack/patchbay/server/index.js" && -d "$TMPDIR_/unpack/patchbay/web-dist" ]] \
|
||||
|| die "包内布局和预期不符(应有 patchbay/server/index.js 和 patchbay/web-dist/),已放弃"
|
||||
|
||||
# 应用目录无状态(配置和令牌都在别处),留一份旧版用于出问题时手动换回
|
||||
rm -rf "$PREFIX/app.prev"
|
||||
[[ -d "$PREFIX/app" ]] && mv "$PREFIX/app" "$PREFIX/app.prev"
|
||||
mv "$TMPDIR_/unpack/patchbay" "$PREFIX/app"
|
||||
ok "已安装 $target → $PREFIX/app"
|
||||
}
|
||||
|
||||
# ───────────────────────── 环境变量文件 ─────────────────────────
|
||||
# 各项的含义见仓库里的 server/env.ts;这里从 mihomo 的现有配置直接推导,
|
||||
# 装完即可用,不用人工填一遍已经存在的信息。
|
||||
write_env() {
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
info "环境变量文件已存在,保持不动: $ENV_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local conf="$MIHOMO_CONF_DIR/config.yaml"
|
||||
[[ -f "$conf" ]] || die "找不到 $conf。先用 mihomo-up.sh 装好 mihomo 再来装 Patchbay"
|
||||
[[ -x "$MIHOMO_BIN" ]] || die "找不到 mihomo 二进制 $MIHOMO_BIN(装在别处就用 MIHOMO_BIN=路径 覆盖)"
|
||||
|
||||
local ctrl secret
|
||||
ctrl="$(conf_get 'external-controller')"
|
||||
secret="$(conf_get 'secret')"
|
||||
[[ -n "$ctrl" && "$ctrl" != '""' ]] \
|
||||
|| die "配置里没开管理接口(external-controller),Patchbay 靠它读线路和重载。先用 mihomo-up.sh 的配置向导开起来"
|
||||
|
||||
# Patchbay 和内核在同一台机器上,管理接口绑了 0.0.0.0 也从环回地址连
|
||||
local api_host="${ctrl%:*}" api_port="${ctrl##*:}"
|
||||
[[ "$api_host" == "0.0.0.0" || -z "$api_host" ]] && api_host="127.0.0.1"
|
||||
|
||||
local port
|
||||
ask_default " Patchbay 监听端口" "7580" port
|
||||
[[ "$port" =~ ^[0-9]+$ ]] || die "端口要是数字,收到的是「$port」"
|
||||
|
||||
mkdir -p "$(dirname "$ENV_FILE")"
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# Patchbay 环境变量 — 由 install.sh v${SELF_VERSION} 生成于 $(date '+%F %T')
|
||||
# 各项含义见 ${REPO_URL}(server/env.ts)。改完 systemctl restart ${SERVICE} 生效。
|
||||
MIHOMO_API=http://${api_host}:${api_port}
|
||||
MIHOMO_SECRET=${secret}
|
||||
PATCHBAY_CONFIG=${conf}
|
||||
PATCHBAY_MIHOMO_BIN=${MIHOMO_BIN}
|
||||
PATCHBAY_MIHOMO_HOME=${MIHOMO_CONF_DIR}
|
||||
PATCHBAY_HOST=127.0.0.1
|
||||
PATCHBAY_PORT=${port}
|
||||
EOF
|
||||
# 里面有管理接口的密钥,不让其它用户读
|
||||
chmod 0640 "$ENV_FILE"
|
||||
ok "已生成 $ENV_FILE ${D}(权限 0640,密钥在里面)${N}"
|
||||
}
|
||||
|
||||
# ───────────────────────── systemd 服务 ─────────────────────────
|
||||
write_unit() {
|
||||
cat > "$1" <<EOF
|
||||
[Unit]
|
||||
Description=Patchbay — mihomo 配置的网页界面
|
||||
Documentation=${REPO_URL}
|
||||
# 只是排序不是依赖: 内核连不上时 Patchbay 照样能起来编辑配置
|
||||
After=network-online.target mihomo.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
ExecStart=${PREFIX}/bun/bun ${PREFIX}/app/server/index.js
|
||||
WorkingDirectory=${PREFIX}/app
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
install_unit() {
|
||||
command -v systemctl >/dev/null 2>&1 \
|
||||
|| die "这台机器没有 systemd。手动运行: $PREFIX/bun/bun $PREFIX/app/server/index.js(环境变量见 $ENV_FILE)"
|
||||
local tmp; tmp="$(mktemp)"
|
||||
write_unit "$tmp"
|
||||
if [[ -f "$UNIT_FILE" ]] && cmp -s "$tmp" "$UNIT_FILE"; then
|
||||
rm -f "$tmp"; info "服务文件已是最新,没改动"
|
||||
else
|
||||
install -m 0644 "$tmp" "$UNIT_FILE"; rm -f "$tmp"
|
||||
ok "已写入 $UNIT_FILE"
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
}
|
||||
|
||||
start_service() {
|
||||
systemctl enable "$SERVICE" >/dev/null 2>&1 && info "已设为开机自启"
|
||||
systemctl restart "$SERVICE" || true
|
||||
sleep 2
|
||||
if svc_running; then
|
||||
ok "$SERVICE 已启动"
|
||||
else
|
||||
warn "$SERVICE 没有启动成功。看具体原因: journalctl -u $SERVICE -n 50 --no-pager"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
show_access() {
|
||||
local port token=""
|
||||
port="$(sed -n -E 's/^PATCHBAY_PORT=//p' "$ENV_FILE" 2>/dev/null | sed -n '1p')"
|
||||
# 访问令牌由服务端首次启动时生成,存在配置文件旁边(见 server/auth.ts)
|
||||
[[ -f "$MIHOMO_CONF_DIR/.patchbay-token" ]] && token="$(tr -d '[:space:]' < "$MIHOMO_CONF_DIR/.patchbay-token")"
|
||||
|
||||
log ""
|
||||
log " ${D}只监听本机。从你自己的电脑访问,先开一条 SSH 隧道:${N}"
|
||||
log " ${W}ssh -N -L ${port:-7580}:127.0.0.1:${port:-7580} 用户名@这台机器${N}"
|
||||
log " ${D}然后浏览器打开:${N}"
|
||||
log " ${W}http://127.0.0.1:${port:-7580}${N}"
|
||||
if [[ -n "$token" ]]; then
|
||||
log " ${D}访问令牌${N} ${W}${token}${N}"
|
||||
log " ${D}令牌存在 $MIHOMO_CONF_DIR/.patchbay-token,忘了随时回来看。${N}"
|
||||
else
|
||||
log " ${D}访问令牌在服务首次启动时生成: journalctl -u $SERVICE | grep 令牌${N}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ───────────────────────── 命令 ─────────────────────────
|
||||
do_install() {
|
||||
require_root "安装到 $PREFIX 并写入 systemd 服务"
|
||||
need curl; need tar
|
||||
TMPDIR_="$(mktemp -d)"
|
||||
|
||||
local target cur
|
||||
target="${WANT_VERSION:-$(latest_version)}"
|
||||
cur="$(installed_version)"
|
||||
if [[ "$cur" == "$target" && $FORCE -eq 0 ]]; then
|
||||
ok "已是 $cur,无需操作(--force 可重装)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "${W}Patchbay ${target}${N} ${D}${cur:+(当前 $cur)}→ $PREFIX${N}"
|
||||
install_bun
|
||||
install_app "$target"
|
||||
write_env
|
||||
install_unit
|
||||
start_service || return 1
|
||||
show_access
|
||||
}
|
||||
|
||||
do_check() {
|
||||
local cur latest
|
||||
cur="$(installed_version)"
|
||||
latest="$(latest_version)"
|
||||
if [[ -z "$cur" ]]; then
|
||||
log "未安装(最新 Release 是 $latest)"; exit 10
|
||||
elif [[ "$cur" == "$latest" ]]; then
|
||||
log "已是最新 ($cur)"; exit 0
|
||||
else
|
||||
log "有新版: $cur → $latest"; exit 10
|
||||
fi
|
||||
}
|
||||
|
||||
do_uninstall() {
|
||||
require_root "卸载 Patchbay"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl disable --now "$SERVICE" >/dev/null 2>&1 || true
|
||||
rm -f "$UNIT_FILE"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
rm -rf "$PREFIX"
|
||||
ok "已删除 $PREFIX 和 systemd 服务"
|
||||
if (( PURGE )); then
|
||||
rm -f "$ENV_FILE" "$MIHOMO_CONF_DIR/.patchbay-token"
|
||||
rmdir "$(dirname "$ENV_FILE")" 2>/dev/null || true
|
||||
ok "已删除环境变量文件和访问令牌"
|
||||
else
|
||||
info "留下了 $ENV_FILE 和访问令牌,重装可以继续用;不要了加 --purge"
|
||||
fi
|
||||
}
|
||||
|
||||
# ───────────────────────── 入口 ─────────────────────────
|
||||
CMD="install"
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
install|check|uninstall) CMD="$1" ;;
|
||||
-y|--yes) ASSUME_YES=1 ;;
|
||||
--force) FORCE=1 ;;
|
||||
--purge) PURGE=1 ;;
|
||||
--version) shift; WANT_VERSION="${1:-}"; [[ -n "$WANT_VERSION" ]] || die "--version 后面要跟版本号" ;;
|
||||
--mirror) shift; MIRROR="${1:-}"; [[ -n "$MIRROR" ]] || die "--mirror 后面要跟地址" ;;
|
||||
-h|--help) usage ;;
|
||||
-v) printf '%s\n' "$SELF_VERSION"; exit 0 ;;
|
||||
*) die "不认识的参数: $1(--help 看用法)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
case "$CMD" in
|
||||
install) do_install ;;
|
||||
check) do_check ;;
|
||||
uninstall) do_uninstall ;;
|
||||
esac
|
||||
+6
-3
@@ -7,17 +7,20 @@
|
||||
"scripts": {
|
||||
"start": "vite build && bun run server/index.ts",
|
||||
"dev": "bun run --hot server/index.ts",
|
||||
"dev:all": "bun run scripts/dev-all.ts",
|
||||
"dev:web": "vite",
|
||||
"dev:kernel": "../binary/mihomo-windows-amd64-v3-go125.exe -d dev/home -f dev/home/config.yaml",
|
||||
"dev:reset": "cp dev/config.yaml dev/home/config.yaml",
|
||||
"dev:reset": "bun run scripts/dev-reset.ts",
|
||||
"build": "vite build",
|
||||
"test": "bun test",
|
||||
"smoke": "bun run scripts/api-smoke.ts",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"yaml": "^2.9.0",
|
||||
"vue": "^3.5.13"
|
||||
"@tanstack/vue-virtual": "^3.13.35",
|
||||
"echarts": "^6.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/ri": "^1.2.10",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 开发模式:一条命令同时起后端和前端。
|
||||
*
|
||||
* 后端带 --hot,改了服务端代码自己重载;前端是 Vite,改了界面代码浏览器立刻更新,
|
||||
* 两边都不用手动重启。访问 Vite 那个地址(5173),它会把接口请求转发给后端。
|
||||
*
|
||||
* 打包后的 `bun run start` 不具备这个能力:它把前端打成静态文件再由后端提供,
|
||||
* 代码一改就得重新打包重启。那种方式适合最终验证和部署。
|
||||
*/
|
||||
export {}
|
||||
|
||||
const procs = [
|
||||
Bun.spawn(['bun', 'run', '--hot', 'server/index.ts'], { stdio: ['inherit', 'inherit', 'inherit'] }),
|
||||
Bun.spawn(['bunx', 'vite'], { stdio: ['inherit', 'inherit', 'inherit'] }),
|
||||
]
|
||||
|
||||
const stopAll = () => {
|
||||
for (const p of procs) p.kill()
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', stopAll)
|
||||
process.on('SIGTERM', stopAll)
|
||||
|
||||
await Promise.race(procs.map((p) => p.exited))
|
||||
stopAll()
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 从模板生成本地测试内核的配置。
|
||||
*
|
||||
* 模板 dev/config.yaml 进 git,生成的 dev/home/config.yaml 不进——因为订阅地址里
|
||||
* 通常带访问令牌。所以模板里写占位符,这个脚本从 .env 取真值替换。
|
||||
*
|
||||
* 用法:bun run dev:reset
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
|
||||
const TEMPLATE = './dev/config.yaml'
|
||||
const TARGET = './dev/home/config.yaml'
|
||||
const PLACEHOLDER = '__SUBSCRIPTION_URL__'
|
||||
|
||||
const url = Bun.env.DEV_SUBSCRIPTION_URL
|
||||
const template = await readFile(TEMPLATE, 'utf8')
|
||||
|
||||
if (template.includes(PLACEHOLDER) && !url) {
|
||||
console.error(`模板里有 ${PLACEHOLDER},但 .env 里没有 DEV_SUBSCRIPTION_URL。`)
|
||||
console.error('填一个能拉到线路的订阅地址;开发时通常是 SSH 隧道映射过来的 Sub-Store。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await mkdir('./dev/home', { recursive: true })
|
||||
await writeFile(TARGET, template.replaceAll(PLACEHOLDER, url ?? ''), 'utf8')
|
||||
console.log(`${TARGET} 已从模板重新生成`)
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 验证「每路一个专属代理组」和链式代理生成的配置内核真的接受。
|
||||
*
|
||||
* 单元测试只能证明字符串拼对了,证明不了内核认。这里走一遍真实的写回路径,
|
||||
* 产物交给 mihomo -t 校验。特别要看的是订阅副本上的 dialer-proxy 引用能不能过
|
||||
* `validateDialerProxies`(内核源码 config/utils.go:148-176)。
|
||||
*
|
||||
* 用法:bun run scripts/verify-chain.ts <源配置> <输出路径>
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { openDocument } from '../vendor/config-editor/index.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { rewriteYaml } from '../server/config-store.ts'
|
||||
import { applySetup, pruneGenerated } from '../shared/inbound-groups.ts'
|
||||
|
||||
const [src, out] = process.argv.slice(2)
|
||||
if (!src || !out) {
|
||||
console.error('用法:bun run scripts/verify-chain.ts <源配置> <输出路径>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const source = readFileSync(src, 'utf8')
|
||||
const data = JSON.parse(JSON.stringify(openDocument(source).data)) as ConfigObject
|
||||
|
||||
// 模拟界面上的三个动作:新增两路入站,一路直连,一路开链式代理并把入口筛到某一条线
|
||||
data.listeners = [
|
||||
{ name: '容器A', type: 'mixed', port: 17895 },
|
||||
{ name: '容器B', type: 'mixed', port: 17896 },
|
||||
]
|
||||
|
||||
let next = applySetup(data, '容器A', {
|
||||
proxy: { sources: ['subs'], filter: null },
|
||||
chain: null,
|
||||
})
|
||||
next = applySetup(next, '容器B', {
|
||||
proxy: { sources: ['subs'], filter: null },
|
||||
chain: { sources: ['subs'], filter: '(?i)DMIT' },
|
||||
})
|
||||
|
||||
writeFileSync(out, rewriteYaml(source, pruneGenerated(next)), 'utf8')
|
||||
|
||||
console.log('生成的策略组:')
|
||||
console.log(JSON.stringify(next['proxy-groups'], null, 2))
|
||||
// 副本是 type: file,makeChainProvider 已去掉 url,整条回显没有泄露订阅地址的风险
|
||||
console.log('\n生成的订阅副本:')
|
||||
for (const [name, entry] of Object.entries(next['proxy-providers'] as ConfigObject)) {
|
||||
if (!name.startsWith('patchbay:')) continue
|
||||
console.log(name, JSON.stringify(entry))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { tokenFromRequest, tokenMatches } from './auth.ts'
|
||||
|
||||
const TOKEN = '208ac4512b9748b9b7408ebc16ec793d'
|
||||
|
||||
describe('令牌比较', () => {
|
||||
it('一模一样才算通过', () => {
|
||||
expect(tokenMatches(TOKEN, TOKEN)).toBe(true)
|
||||
})
|
||||
|
||||
it('差一个字符就不通过', () => {
|
||||
expect(tokenMatches(TOKEN, `${TOKEN.slice(0, -1)}x`)).toBe(false)
|
||||
})
|
||||
|
||||
it('前缀正确也不算通过', () => {
|
||||
// 逐字节比较写错时最容易漏掉的一种:只比对了开头
|
||||
expect(tokenMatches(TOKEN, TOKEN.slice(0, 8))).toBe(false)
|
||||
})
|
||||
|
||||
it('更长的字符串不算通过', () => {
|
||||
expect(tokenMatches(TOKEN, `${TOKEN}extra`)).toBe(false)
|
||||
})
|
||||
|
||||
it('没带令牌不算通过', () => {
|
||||
expect(tokenMatches(TOKEN, null)).toBe(false)
|
||||
expect(tokenMatches(TOKEN, '')).toBe(false)
|
||||
})
|
||||
|
||||
it('比较耗时不随匹配长度变化', () => {
|
||||
// 这是常数时间比较存在的理由:如果先匹配的字符越多越慢,就能靠计时逐字节猜出令牌。
|
||||
// 这里只验证行为上的对称——两个都不匹配的输入走的是同样多的比较。
|
||||
const almost = `${TOKEN.slice(0, -1)}x`
|
||||
const nothing = 'x'.repeat(TOKEN.length)
|
||||
expect(tokenMatches(TOKEN, almost)).toBe(false)
|
||||
expect(tokenMatches(TOKEN, nothing)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('从请求里取令牌', () => {
|
||||
const req = (init?: RequestInit, url = 'http://x/api/config') => new Request(url, init)
|
||||
|
||||
it('认 Authorization 头', () => {
|
||||
expect(tokenFromRequest(req({ headers: { Authorization: `Bearer ${TOKEN}` } }))).toBe(TOKEN)
|
||||
})
|
||||
|
||||
it('认查询参数,WebSocket 用得上——它没法自定义请求头', () => {
|
||||
expect(tokenFromRequest(req(undefined, `http://x/api/logs?token=${TOKEN}`))).toBe(TOKEN)
|
||||
})
|
||||
|
||||
it('头的格式不对时当作没带', () => {
|
||||
expect(tokenFromRequest(req({ headers: { Authorization: TOKEN } }))).toBeNull()
|
||||
expect(tokenFromRequest(req({ headers: { Authorization: `Basic ${TOKEN}` } }))).toBeNull()
|
||||
})
|
||||
|
||||
it('什么都没带时返回空', () => {
|
||||
expect(tokenFromRequest(req())).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,336 @@
|
||||
import { afterAll, describe, expect, it } from 'bun:test'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
applyConfig,
|
||||
backupDiff,
|
||||
importConfig,
|
||||
listBackups,
|
||||
listRedo,
|
||||
readConfig,
|
||||
redoConfig,
|
||||
restoreBackup,
|
||||
undoConfig,
|
||||
} from './config-store.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/**
|
||||
* 落盘保护链路的测试,跑在一个假内核上:/version 报在线,PUT /configs 默认放行。
|
||||
* mihomo -t 那条离线路径这里测不了(需要真的内核二进制),离线行为只能靠人工验证。
|
||||
*/
|
||||
let rejectNextReload = false
|
||||
let reloadCount = 0
|
||||
const kernel = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/version') return Response.json({ version: 'test' })
|
||||
if (url.pathname === '/configs' && req.method === 'PUT') {
|
||||
reloadCount += 1
|
||||
if (rejectNextReload) {
|
||||
rejectNextReload = false
|
||||
return new Response('parse error: something wrong', { status: 400 })
|
||||
}
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
afterAll(() => kernel.stop())
|
||||
|
||||
const ORIGINAL = 'mixed-port: 7890\n'
|
||||
|
||||
function makeEnv(): Env {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'patchbay-store-'))
|
||||
const configPath = join(dir, 'config.yaml')
|
||||
writeFileSync(configPath, ORIGINAL, 'utf8')
|
||||
return {
|
||||
mihomoApi: `http://127.0.0.1:${String(kernel.port)}`,
|
||||
mihomoSecret: 'x',
|
||||
configPath,
|
||||
mihomoBin: join(dir, '不存在的内核'),
|
||||
mihomoHome: dir,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('落盘保护链路(内核在线)', () => {
|
||||
it('正常保存:写盘、留一份改动前的备份、通知重载', async () => {
|
||||
const env = makeEnv()
|
||||
const { data, revision } = await readConfig(env)
|
||||
const before = reloadCount
|
||||
|
||||
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.reloaded).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7891')
|
||||
expect(reloadCount).toBe(before + 1)
|
||||
|
||||
const backups = await listBackups(env)
|
||||
expect(backups).toHaveLength(1)
|
||||
const backupBody = readFileSync(
|
||||
join(env.configPath, '..', '.patchbay-backups', backups[0]!),
|
||||
'utf8',
|
||||
)
|
||||
expect(backupBody).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('基准过时被拦住,文件一个字节不动', async () => {
|
||||
const env = makeEnv()
|
||||
const { data } = await readConfig(env)
|
||||
|
||||
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, '基准已过时')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.stage).toBe('stale')
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
expect(await listBackups(env)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('内容没变直接返回,不备份也不重载', async () => {
|
||||
const env = makeEnv()
|
||||
// 先落一次盘让文件带上 Patchbay 标记——没有标记的文件第一次保存必有
|
||||
// 「补标记」这一处内容变化,短路只发生在稳态
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, first.data, first.revision)
|
||||
const { data, revision } = await readConfig(env)
|
||||
const before = reloadCount
|
||||
const backupsBefore = (await listBackups(env)).length
|
||||
|
||||
const result = await applyConfig(env, data, revision)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.reloaded).toBe(false)
|
||||
expect(result.reloadMessage).toBe('内容没有变化')
|
||||
}
|
||||
expect(reloadCount).toBe(before)
|
||||
expect((await listBackups(env)).length).toBe(backupsBefore)
|
||||
})
|
||||
|
||||
it('自检拦下断引用:入站的 proxy 指向不存在的出站', async () => {
|
||||
// mihomo -t 和重载都不检查这种引用(实测 v1.19.29),只有这层自检拦得住
|
||||
const env = makeEnv()
|
||||
const { data, revision } = await readConfig(env)
|
||||
const next = {
|
||||
...data,
|
||||
listeners: [{ name: 'x', type: 'mixed', port: 1080, proxy: '不存在的组' }],
|
||||
}
|
||||
|
||||
const result = await applyConfig(env, next, revision)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.stage).toBe('self-check')
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('内核拒绝重载时把文件还原,并让内核载回原样', async () => {
|
||||
const env = makeEnv()
|
||||
const { data, revision } = await readConfig(env)
|
||||
|
||||
rejectNextReload = true
|
||||
const result = await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.stage).toBe('reload')
|
||||
expect(result.restored).toBe(true)
|
||||
expect(result.message).toContain('parse error')
|
||||
}
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('恢复备份走同一条保护链路', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
|
||||
const [backup] = await listBackups(env)
|
||||
|
||||
const result = await restoreBackup(env, backup!)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
// 还原也走落盘链路,所以会重新打上 Patchbay 标记——内容回去了,但不逐字节等于旧文件
|
||||
const body = readFileSync(env.configPath, 'utf8')
|
||||
expect(body).toContain('mixed-port: 7890')
|
||||
expect(body).not.toContain('7891')
|
||||
expect(body).toContain('patchbay:')
|
||||
})
|
||||
|
||||
it('覆盖导入整份替换,兼容导入只搬接管段落', async () => {
|
||||
const env = makeEnv()
|
||||
const uploaded = 'mixed-port: 9999\nrules:\n - MATCH,DIRECT\n'
|
||||
|
||||
const compat = await importConfig(env, uploaded, 'compatible')
|
||||
expect(compat.ok).toBe(true)
|
||||
let body = readFileSync(env.configPath, 'utf8')
|
||||
// rules 是接管段落被搬过来,mixed-port 属于机器底座维持当前值
|
||||
expect(body).toContain('MATCH,DIRECT')
|
||||
expect(body).toContain('mixed-port: 7890')
|
||||
expect(body).not.toContain('9999')
|
||||
|
||||
const over = await importConfig(env, uploaded, 'override')
|
||||
expect(over.ok).toBe(true)
|
||||
body = readFileSync(env.configPath, 'utf8')
|
||||
expect(body).toContain('mixed-port: 9999')
|
||||
})
|
||||
|
||||
it('导入解析不过的文件时一个字节都不动', async () => {
|
||||
const env = makeEnv()
|
||||
const result = await importConfig(env, '{{{ 不是 yaml', 'override')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.stage).toBe('self-check')
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('恢复只认列表里的名字,路径穿越进不来', async () => {
|
||||
const env = makeEnv()
|
||||
const result = await restoreBackup(env, '../config.yaml')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.message).toContain('没有这个备份')
|
||||
})
|
||||
|
||||
it('撤回回到改动前,重做拿回改动,备份链不被撤回污染', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
|
||||
|
||||
const undone = await undoConfig(env)
|
||||
expect(undone.ok).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||||
// 备份被消费、当前状态进了重做栈
|
||||
expect(await listBackups(env)).toHaveLength(0)
|
||||
expect(await listRedo(env)).toHaveLength(1)
|
||||
|
||||
const redone = await redoConfig(env)
|
||||
expect(redone.ok).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7891')
|
||||
expect(await listRedo(env)).toHaveLength(0)
|
||||
expect(await listBackups(env)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('栈空时撤回和重做被拦住,文件不动', async () => {
|
||||
const env = makeEnv()
|
||||
const undone = await undoConfig(env)
|
||||
expect(undone.ok).toBe(false)
|
||||
if (!undone.ok) expect(undone.message).toContain('没有可以撤回')
|
||||
|
||||
const redone = await redoConfig(env)
|
||||
expect(redone.ok).toBe(false)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('新的保存清空重做栈', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
|
||||
await undoConfig(env)
|
||||
expect(await listRedo(env)).toHaveLength(1)
|
||||
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7892 }, revision)
|
||||
expect(await listRedo(env)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('备份最多留 10 份,旧的被清掉', async () => {
|
||||
const env = makeEnv()
|
||||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
for (let i = 10; i < 22; i += 1) {
|
||||
writeFileSync(join(dir, `config.yaml.bak-20200101-0000${String(i)}`), 'old', 'utf8')
|
||||
}
|
||||
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
|
||||
|
||||
const backups = await listBackups(env)
|
||||
expect(backups).toHaveLength(10)
|
||||
// 名字按时间戳排序,留下的是最新的:刚生成的那份在最前,2020 年的只剩最近的九份
|
||||
expect(backups[0]!.startsWith('config.yaml.bak-2020')).toBe(false)
|
||||
expect(backups).not.toContain('config.yaml.bak-20200101-000010')
|
||||
})
|
||||
})
|
||||
|
||||
describe('连续保存的合并窗(opts.merge)', () => {
|
||||
const stampName = (at: Date) => {
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return (
|
||||
`config.yaml.bak-${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
|
||||
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`
|
||||
)
|
||||
}
|
||||
|
||||
it('窗内的第二次保存不新建备份,一步撤回回到这一串之前', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
|
||||
const second = await readConfig(env)
|
||||
await applyConfig(env, { ...second.data, 'mixed-port': 7892 }, second.revision, { merge: true })
|
||||
|
||||
expect(await listBackups(env)).toHaveLength(1)
|
||||
|
||||
const undone = await undoConfig(env)
|
||||
expect(undone.ok).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||||
})
|
||||
|
||||
it('最新备份出了窗就正常新建', async () => {
|
||||
const env = makeEnv()
|
||||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, stampName(new Date(Date.now() - 60_000))), 'old', 'utf8')
|
||||
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision, { merge: true })
|
||||
|
||||
expect(await listBackups(env)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('不带 merge 的保存(还原、导入)不合并,窗内照样各留一条', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
|
||||
const [backup] = await listBackups(env)
|
||||
|
||||
const result = await restoreBackup(env, backup!)
|
||||
expect(result.ok).toBe(true)
|
||||
// 还原自己也备份了「还原前」的状态。名字按秒生成,和上一份同秒时会覆盖而不是
|
||||
// 新增,所以这里断言的是「至少还有一份、且文件回到了原样」
|
||||
expect((await listBackups(env)).length).toBeGreaterThanOrEqual(1)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||||
})
|
||||
})
|
||||
|
||||
describe('历史记录的对比原文', () => {
|
||||
it('最新一条对比当前文件,改动前后各归各', async () => {
|
||||
const env = makeEnv()
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
|
||||
|
||||
const [newest] = await listBackups(env)
|
||||
const pair = await backupDiff(env, newest!)
|
||||
expect(pair).not.toBeNull()
|
||||
expect(pair!.before).toBe(ORIGINAL)
|
||||
expect(pair!.after).toContain('mixed-port: 7891')
|
||||
})
|
||||
|
||||
it('更老的一条对比它前面那份备份', async () => {
|
||||
const env = makeEnv()
|
||||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'config.yaml.bak-20200101-000001'), '第一份\n', 'utf8')
|
||||
writeFileSync(join(dir, 'config.yaml.bak-20200101-000002'), '第二份\n', 'utf8')
|
||||
|
||||
const pair = await backupDiff(env, 'config.yaml.bak-20200101-000001')
|
||||
expect(pair).toEqual({ before: '第一份\n', after: '第二份\n' })
|
||||
})
|
||||
|
||||
it('名字不在备份列表里返回 null', async () => {
|
||||
const env = makeEnv()
|
||||
expect(await backupDiff(env, '../../etc/passwd')).toBeNull()
|
||||
})
|
||||
})
|
||||
+311
-30
@@ -8,8 +8,14 @@
|
||||
*
|
||||
* 顺序是:
|
||||
* 读当前文件 → 检查是否被外部改过 → 生成新内容 → 自己检查一遍
|
||||
* → 写到临时文件 → mihomo -t 校验 → 备份原文件 → 写入 → 通知内核重载
|
||||
* 最后两步中任何一步出问题,都从备份还原并让内核载回原样。
|
||||
* → 备份原文件 → 写入 → 让内核重载,被拒就从备份还原并载回原样
|
||||
* 重载这一步自带校验:内核先完整解析、解析失败什么都不应用并返回 400
|
||||
* (内核源码 hub/route/configs.go:430-438),还原链路补上「把文件退回去」。
|
||||
*
|
||||
* 内核不在线时没有这层校验,换成:
|
||||
* → 写到临时文件 → mihomo -t 校验 → 备份 → 写入,不重载,内核下次启动时生效
|
||||
* 配置写坏导致内核起不来的时候正是最需要这个界面的时候,这条路必须格外稳,
|
||||
* 也绝不能因为「重载失败」把刚修好的文件又还原回去。
|
||||
*
|
||||
* 「自己检查一遍」不能省。实测 v1.19.29:入站的 `proxy:` 指向一个不存在的
|
||||
* 出站时,`mihomo -t` 会通过、重载会成功,直到有流量走到那个入站才在日志里
|
||||
@@ -24,17 +30,33 @@ import {
|
||||
} from '../vendor/config-editor/index.ts'
|
||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts'
|
||||
import { validateConfigFile, reloadConfig } from './kernel.ts'
|
||||
import { backupDate } from '../shared/backups.ts'
|
||||
import { withMarker } from '../shared/config-marker.ts'
|
||||
import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/** 备份保留的份数。再多没有意义——真要回到很久以前的版本,那是版本控制的活。 */
|
||||
const KEEP_BACKUPS = 10
|
||||
|
||||
/**
|
||||
* 界面上的连续保存合并成一条历史的时间窗:最新备份还在窗内就不再新建,
|
||||
* 这一串操作共享一条记录、一步撤回。连点删除不再刷出一屏历史。
|
||||
* 顺带解决了备份名只精确到秒的隐患——窗内不建新备份,同秒的覆盖不会发生。
|
||||
*/
|
||||
const MERGE_WINDOW_MS = 5_000
|
||||
|
||||
/** 最新备份是否还在合并窗内。时间戳从备份名里解析,时钟往回跳时当不在窗内。 */
|
||||
async function hasRecentBackup(env: Env): Promise<boolean> {
|
||||
const [newest] = await listBackups(env)
|
||||
const at = newest ? backupDate(newest) : null
|
||||
if (!at) return false
|
||||
const age = Date.now() - at.getTime()
|
||||
return age >= 0 && age < MERGE_WINDOW_MS
|
||||
}
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
/** 解析出来的配置数据 */
|
||||
data: ConfigObject
|
||||
/** 文件原文 */
|
||||
yaml: string
|
||||
/**
|
||||
* 内容指纹。客户端改完提交时要带上它,服务端据此判断这份改动
|
||||
* 是不是基于最新的文件——中途有人 SSH 上去手改过就会对不上。
|
||||
@@ -56,7 +78,12 @@ export type ApplyFailureStage =
|
||||
| 'write' // 写文件失败
|
||||
| 'reload' // 文件写进去了,但内核没能重新加载
|
||||
|
||||
export async function readConfig(env: Env): Promise<ConfigSnapshot> {
|
||||
/** 内部用的完整读取,带原文。原文只在生成新内容时需要,不下发给前端。 */
|
||||
interface ConfigDocument extends ConfigSnapshot {
|
||||
yaml: string
|
||||
}
|
||||
|
||||
async function readDocument(env: Env): Promise<ConfigDocument> {
|
||||
const yaml = await readFile(env.configPath, 'utf8')
|
||||
const doc = openDocument(yaml)
|
||||
return {
|
||||
@@ -67,18 +94,50 @@ export async function readConfig(env: Env): Promise<ConfigSnapshot> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读当前配置。
|
||||
*
|
||||
* 不含文件原文:前端从来不读它,带上只会让每次请求的响应体积翻倍。要原文的地方
|
||||
* 在这个文件内部,用 readDocument。
|
||||
*/
|
||||
export async function readConfig(env: Env): Promise<ConfigSnapshot> {
|
||||
const { yaml: _yaml, ...snapshot } = await readDocument(env)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* 把新的配置数据写成 YAML 文本。
|
||||
*
|
||||
* 单独导出是为了让测试能走**生产用的这一条**路径。之前测试自己拼了
|
||||
* 「解析 → 替换 → 还原对齐」三步,于是如果有人在落盘链路里漏掉最后那步,
|
||||
* 那些专为守护它而写的测试照样全绿。
|
||||
*/
|
||||
export function rewriteYaml(currentYaml: string, nextData: ConfigObject): string {
|
||||
const rewritten = replaceDocumentData(openDocument(currentYaml), nextData)
|
||||
// 把只是丢了对齐空格的行还原成原样,否则没被改动的段落也会出现在 diff 里,见 ORIGIN.md
|
||||
return restoreInlineCommentAlignment(currentYaml, rewritten.yaml)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一份新的配置数据落盘。
|
||||
*
|
||||
* `baseRevision` 是客户端读到这份配置时拿到的指纹。传 undefined 表示
|
||||
* 「不管别人改没改,以我为准」,仅用于还原备份这类明确要覆盖的场景。
|
||||
*
|
||||
* `opts.restoreFrom`:撤回/重做专用。给了它就不再新建备份(否则备份链会被
|
||||
* 撤回本身污染,撤两次等于原地打转),重载失败时用它当还原源;同时不清空
|
||||
* 重做栈——只有「正常的新改动」才让已撤回的未来失效。
|
||||
*
|
||||
* `opts.merge`:界面上的普通保存专用。最新备份还在合并窗内时不新建备份,
|
||||
* 连续操作共享一条历史。还原备份、上传导入这类独立动作不传,各占一条。
|
||||
*/
|
||||
export async function applyConfig(
|
||||
env: Env,
|
||||
nextData: ConfigObject,
|
||||
baseRevision?: string,
|
||||
opts?: { restoreFrom?: string; merge?: boolean },
|
||||
): Promise<ApplyOutcome> {
|
||||
const current = await readConfig(env)
|
||||
const current = await readDocument(env)
|
||||
|
||||
if (baseRevision !== undefined && baseRevision !== current.revision) {
|
||||
return {
|
||||
@@ -90,14 +149,25 @@ export async function applyConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// 生成新内容。restoreInlineCommentAlignment 把只是丢了对齐空格的行还原成原样,
|
||||
// 否则没被改动的段落也会出现在 diff 里,见 ORIGIN.md。
|
||||
const rewritten = replaceDocumentData(openDocument(current.yaml), nextData)
|
||||
const nextYaml = restoreInlineCommentAlignment(current.yaml, rewritten.yaml)
|
||||
// 落盘链路有五步各自可能变慢(校验要起一个内核进程、重载要等内核跑完、备份和写盘是磁盘
|
||||
// IO)。出问题时不靠猜,日志里直接给出每步耗时。
|
||||
const clock = { start: Date.now(), mark: Date.now() }
|
||||
const lap = (stage: string) => {
|
||||
const now = Date.now()
|
||||
const ms = now - clock.mark
|
||||
clock.mark = now
|
||||
return `${stage} ${String(ms)}ms`
|
||||
}
|
||||
|
||||
// 每次落盘都打上 Patchbay 标记(顶层 patchbay: 键,内核忽略未知键)。
|
||||
// 上传配置时靠它认「这是 Patchbay 管理过的配置」。标记是幂等的,
|
||||
// 已带标记的配置重打一遍内容不变,不影响下面的「内容没变就短路」
|
||||
const nextYaml = rewriteYaml(current.yaml, withMarker(nextData))
|
||||
|
||||
// 内容没变就不要走后面那一串——不必要的备份和重载只会制造噪音。
|
||||
if (nextYaml === current.yaml) {
|
||||
return { ok: true, snapshot: current, reloaded: false, reloadMessage: '内容没有变化' }
|
||||
const { yaml: _yaml, ...snapshot } = current
|
||||
return { ok: true, snapshot, reloaded: false, reloadMessage: '内容没有变化' }
|
||||
}
|
||||
|
||||
const problems = validateDocument(nextData).filter((d) => d.severity === 'error')
|
||||
@@ -111,26 +181,39 @@ export async function applyConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// 先写到临时文件交给内核校验,别碰真文件。
|
||||
const tempPath = join(dirname(env.configPath), `.patchbay-check-${String(Date.now())}.yaml`)
|
||||
try {
|
||||
await writeFile(tempPath, nextYaml, 'utf8')
|
||||
const checked = await validateConfigFile(env, tempPath)
|
||||
if (!checked.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'kernel-check',
|
||||
message: checked.message,
|
||||
hint: '内核拒绝了这份配置,真文件一个字节都没动,你可以直接继续改。',
|
||||
restored: false,
|
||||
// 内核在线时不跑 mihomo -t:起一个进程把配置重新解析一遍占整条链路九成以上的耗时
|
||||
// (实测保存 4-10 秒里它占 4-9.4 秒,重载只要几十到几百毫秒),而重载请求自带同一套
|
||||
// 解析、失败时什么都不应用,还原链路补上「把文件退回去」,效果等同校验。代价是写盘到
|
||||
// 还原之间的短暂窗口里磁盘上存在一份可能有错的配置,进程恰好在这时被杀才会留下事故。
|
||||
// 内核不在线时 mihomo -t 是唯一的校验手段。
|
||||
const kernel = await kernelVersion(env)
|
||||
let checkTiming = ''
|
||||
if (!kernel.online) {
|
||||
// 先写到临时文件交给内核校验,别碰真文件。
|
||||
const tempPath = join(dirname(env.configPath), `.patchbay-check-${String(Date.now())}.yaml`)
|
||||
try {
|
||||
await writeFile(tempPath, nextYaml, 'utf8')
|
||||
const checked = await validateConfigFile(env, tempPath)
|
||||
checkTiming = lap('内核校验')
|
||||
if (!checked.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'kernel-check',
|
||||
message: checked.message,
|
||||
hint: '内核拒绝了这份配置,真文件一个字节都没动,你可以直接继续改。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(tempPath, { force: true })
|
||||
}
|
||||
} finally {
|
||||
await rm(tempPath, { force: true })
|
||||
}
|
||||
|
||||
// 校验过了才动真文件。备份放在写入之前。
|
||||
const backupPath = await createBackup(env)
|
||||
// 校验过了才动真文件。备份放在写入之前;合并窗内跳过新建,重载失败的还原
|
||||
// 不受影响——没有备份文件时用刚读出的原文写回(见下面的还原分支)。
|
||||
const merged = opts?.merge === true && opts.restoreFrom === undefined && (await hasRecentBackup(env))
|
||||
const backupPath = opts?.restoreFrom ?? (merged ? null : await createBackup(env))
|
||||
const backupTiming = backupPath !== null && opts?.restoreFrom === undefined ? lap('备份') : ''
|
||||
try {
|
||||
await writeFile(env.configPath, nextYaml, 'utf8')
|
||||
} catch (error) {
|
||||
@@ -143,13 +226,33 @@ export async function applyConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const writeTiming = lap('写盘')
|
||||
|
||||
if (!kernel.online) {
|
||||
// mihomo -t 已经把过关,文件留给内核下次启动时读。
|
||||
await pruneBackups(env)
|
||||
if (!opts?.restoreFrom) await clearRedo(env)
|
||||
console.log(
|
||||
`[apply] 共 ${String(Date.now() - clock.start)}ms(${checkTiming},${backupTiming},${writeTiming},未重载)`,
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: await readConfig(env),
|
||||
reloaded: false,
|
||||
reloadMessage: '内核未连接,改动已写入文件,等内核启动时生效',
|
||||
}
|
||||
}
|
||||
|
||||
const reloaded = await reloadConfig(env, env.configPath)
|
||||
const reloadTiming = lap('重载')
|
||||
if (!reloaded.ok) {
|
||||
// 文件已经写进去了但内核没接受。把文件还原,再让内核载回原样,
|
||||
// 避免留下「文件是新的、内核跑的是旧的」这种最难排查的状态。
|
||||
// 合并窗内没有备份文件,还原源用刚读出的原文。
|
||||
let restored = false
|
||||
try {
|
||||
await copyFile(backupPath, env.configPath)
|
||||
if (backupPath !== null) await copyFile(backupPath, env.configPath)
|
||||
else await writeFile(env.configPath, current.yaml, 'utf8')
|
||||
await reloadConfig(env, env.configPath)
|
||||
restored = true
|
||||
} catch {
|
||||
@@ -161,12 +264,17 @@ export async function applyConfig(
|
||||
message: reloaded.message,
|
||||
hint: restored
|
||||
? '已经把配置还原成改动前的样子,内核也载回去了。改动没有生效。'
|
||||
: `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。`,
|
||||
: backupPath !== null
|
||||
? `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。`
|
||||
: '还原也失败了。历史记录里最新的备份和改动前至多差几秒内的操作,可以从它还原。',
|
||||
restored,
|
||||
}
|
||||
}
|
||||
|
||||
await pruneBackups(env)
|
||||
if (!opts?.restoreFrom) await clearRedo(env)
|
||||
const timings = [checkTiming, backupTiming, writeTiming, reloadTiming].filter(Boolean).join(',')
|
||||
console.log(`[apply] 共 ${String(Date.now() - clock.start)}ms(${timings})`)
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: await readConfig(env),
|
||||
@@ -211,6 +319,179 @@ async function pruneBackups(env: Env): Promise<void> {
|
||||
await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
}
|
||||
|
||||
export type ImportMode = 'override' | 'compatible'
|
||||
|
||||
/**
|
||||
* 兼容模式搬运的段落:Patchbay 接管的那几个(决策记录 0003)。
|
||||
* 老式顶层端口、dns、tun 这类属于机器本身的设置不在其中——兼容恢复的语义是
|
||||
* 「把我在 Patchbay 里做的接线搬过来」,不动当前机器的底座。
|
||||
*/
|
||||
const MANAGED_KEYS = ['listeners', 'proxy-groups', 'rules', 'proxy-providers', 'rule-providers']
|
||||
|
||||
/**
|
||||
* 用上传的 YAML 替换当前配置。
|
||||
*
|
||||
* `override` 整份替换;`compatible` 只把上传文件里 Patchbay 接管的段落搬到
|
||||
* 当前配置上(上传文件里没有的段落维持当前的样子)。解析用和读配置同一个引擎,
|
||||
* 之后走 applyConfig 完整的保护链路(自检、备份、重载、失败回退)。
|
||||
* 没有 baseRevision——替换本来就是覆盖语义,同还原备份。
|
||||
*/
|
||||
export async function importConfig(
|
||||
env: Env,
|
||||
content: string,
|
||||
mode: ImportMode,
|
||||
): Promise<ApplyOutcome> {
|
||||
let uploaded: ConfigObject
|
||||
try {
|
||||
const doc = openDocument(content)
|
||||
const broken = doc.diagnostics.filter((d) => d.severity === 'error')
|
||||
if (broken.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: broken.map((p) => `${p.path.join('.')}:${p.message}`).join('\n'),
|
||||
hint: '上传的文件解析不过,当前配置没有被改动。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
uploaded = doc.data
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: `上传的文件不是合法的 YAML:${String(error)}`,
|
||||
hint: '当前配置没有被改动。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'override') return applyConfig(env, uploaded)
|
||||
|
||||
const current = await readDocument(env)
|
||||
const merged: ConfigObject = { ...current.data }
|
||||
for (const key of MANAGED_KEYS) {
|
||||
const value = uploaded[key]
|
||||
if (value !== undefined) merged[key] = value
|
||||
}
|
||||
return applyConfig(env, merged)
|
||||
}
|
||||
|
||||
/* ── 撤回 / 重做 ──────────────────────────────────
|
||||
*
|
||||
* 撤回栈就是备份链:每次成功落盘都留了「改动前」的备份。撤回把最新备份应用
|
||||
* 回去并**消费掉它**(从链里删除),同时把撤回前的状态推进重做栈;重做反向。
|
||||
* 任何正常的新改动清空重做栈——和编辑器撤回的通行语义一致,不留分叉历史。
|
||||
*/
|
||||
|
||||
async function redoDir(env: Env): Promise<string> {
|
||||
const dir = join(dirname(env.configPath), '.patchbay-redo')
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
/** 重做条目按名字排序就是按时间排序。带毫秒:连续快速撤回不能互相覆盖。 */
|
||||
function redoName(configPath: string, at: Date): string {
|
||||
const p = (n: number, w = 2) => String(n).padStart(w, '0')
|
||||
const stamp =
|
||||
`${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
|
||||
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}${p(at.getMilliseconds(), 3)}`
|
||||
return `${basename(configPath)}.redo-${stamp}`
|
||||
}
|
||||
|
||||
/** 新的在前,同 listBackups。 */
|
||||
export async function listRedo(env: Env): Promise<string[]> {
|
||||
const dir = await redoDir(env)
|
||||
const prefix = `${basename(env.configPath)}.redo-`
|
||||
const entries = await readdir(dir)
|
||||
return entries.filter((name) => name.startsWith(prefix)).sort().reverse()
|
||||
}
|
||||
|
||||
async function clearRedo(env: Env): Promise<void> {
|
||||
const dir = await redoDir(env)
|
||||
const entries = await listRedo(env)
|
||||
await Promise.all(entries.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
}
|
||||
|
||||
/** 当前文件进重做栈,返回落点路径。 */
|
||||
async function pushRedo(env: Env): Promise<string> {
|
||||
const dir = await redoDir(env)
|
||||
const path = join(dir, redoName(env.configPath, new Date()))
|
||||
await copyFile(env.configPath, path)
|
||||
// 和备份同一个上限。裁掉的是最「未来」的状态,同备份链裁掉最远的过去
|
||||
const stale = (await listRedo(env)).slice(KEEP_BACKUPS)
|
||||
await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
return path
|
||||
}
|
||||
|
||||
/** 撤回最近一次改动:应用最新备份并消费掉它,撤回前的状态进重做栈。 */
|
||||
export async function undoConfig(env: Env): Promise<ApplyOutcome> {
|
||||
const [target] = await listBackups(env)
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: '没有可以撤回的改动',
|
||||
hint: '每次保存都会把改动前的状态留进撤回栈,现在栈是空的。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
const dir = await backupDir(env)
|
||||
const targetYaml = await readFile(join(dir, target), 'utf8')
|
||||
const redoPath = await pushRedo(env)
|
||||
const result = await applyConfig(env, openDocument(targetYaml).data, undefined, {
|
||||
restoreFrom: redoPath,
|
||||
})
|
||||
if (result.ok) await unlink(join(dir, target)).catch(() => {})
|
||||
else await unlink(redoPath).catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
/** 重做最近一次被撤回的改动。当前状态回到撤回栈,重做条目被消费。 */
|
||||
export async function redoConfig(env: Env): Promise<ApplyOutcome> {
|
||||
const [target] = await listRedo(env)
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: '没有可以重做的改动',
|
||||
hint: '只有撤回过的改动才能重做,新的保存会清空重做栈。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
const dir = await redoDir(env)
|
||||
const targetYaml = await readFile(join(dir, target), 'utf8')
|
||||
const backupPath = await createBackup(env)
|
||||
const result = await applyConfig(env, openDocument(targetYaml).data, undefined, {
|
||||
restoreFrom: backupPath,
|
||||
})
|
||||
if (result.ok) await unlink(join(dir, target)).catch(() => {})
|
||||
else await unlink(backupPath).catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条历史记录展开时要对比的两份原文。
|
||||
*
|
||||
* 备份存的是「改动前」,所以这次改动的对比对象是更新一份的状态:列表里
|
||||
* 它前面那份备份,最新一条则对比当前文件。名字对着 listBackups 校验,
|
||||
* 同 restoreBackup,防路径穿越。没有这个备份时返回 null。
|
||||
*/
|
||||
export async function backupDiff(
|
||||
env: Env,
|
||||
name: string,
|
||||
): Promise<{ before: string; after: string } | null> {
|
||||
const backups = await listBackups(env)
|
||||
const index = backups.indexOf(name)
|
||||
if (index < 0) return null
|
||||
const dir = await backupDir(env)
|
||||
const before = await readFile(join(dir, name), 'utf8')
|
||||
const after =
|
||||
index === 0
|
||||
? await readFile(env.configPath, 'utf8')
|
||||
: await readFile(join(dir, backups[index - 1]!), 'utf8')
|
||||
return { before, after }
|
||||
}
|
||||
|
||||
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
||||
export async function restoreBackup(env: Env, name: string): Promise<ApplyOutcome> {
|
||||
const dir = await backupDir(env)
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* GEO 数据库文件的本地状态:多大、多久没更新了。
|
||||
*
|
||||
* 内核不提供这个信息,但文件就躺在它的工作目录里,和 Patchbay 服务端
|
||||
* 在同一台机器上,直接 stat 就是。
|
||||
*/
|
||||
import { readdir, rename, stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { DEFAULT_GEOX_URLS, GEO_FILES, looksLikeGeodata, looksLikeMmdb } from '../shared/geo.ts'
|
||||
import type { GeoFileStatus } from '../shared/geo.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/** 配置里写了就用配置的,否则退回内核默认。 */
|
||||
function urlFor(geoxUrl: Record<string, unknown>, configKey: string): string {
|
||||
const configured = geoxUrl[configKey]
|
||||
return typeof configured === 'string' && configured !== ''
|
||||
? configured
|
||||
: (DEFAULT_GEOX_URLS[configKey] ?? '')
|
||||
}
|
||||
|
||||
export async function geoStatus(
|
||||
env: Env,
|
||||
geoxUrl: Record<string, unknown>,
|
||||
): Promise<GeoFileStatus[]> {
|
||||
const entries = await readdir(env.mihomoHome).catch(() => [] as string[])
|
||||
return Promise.all(
|
||||
GEO_FILES.map(async ({ key, configKey, file }) => {
|
||||
// 内核找 GeoIP.dat/GeoSite.dat 时大小写不敏感(constant/path.go:193-224),这里照做
|
||||
const actual = entries.find((name) => name.toLowerCase() === file.toLowerCase())
|
||||
let size: number | null = null
|
||||
let updatedAt: string | null = null
|
||||
if (actual) {
|
||||
const info = await stat(join(env.mihomoHome, actual)).catch(() => null)
|
||||
if (info) {
|
||||
size = info.size
|
||||
updatedAt = info.mtime.toISOString()
|
||||
}
|
||||
}
|
||||
return { key, configKey, url: urlFor(geoxUrl, configKey), size, updatedAt }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function download(url: string, proxy: string | null): Promise<Uint8Array> {
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(180_000),
|
||||
redirect: 'follow',
|
||||
...(proxy ? { proxy } : {}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${String(res.status)}`)
|
||||
return new Uint8Array(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载单个 GEO 文件写进内核工作目录。
|
||||
*
|
||||
* 内核的更新接口是全量的(POST /configs/geo,按 enable 标记更新它用到的类型),
|
||||
* 单文件只能 Patchbay 自己下。代价是拿不到内核那套完整校验,只能做形状检查
|
||||
* (shared/geo.ts),也不触发热加载——内核用 mmap 缓存着旧数据,重启后才换新。
|
||||
*
|
||||
* `proxy` 给的话先经它出去(内核自己更新 GEO 时走的是它自己的分流,直连
|
||||
* GitHub 不一定通,Patchbay 借内核的混合端口能拿到同样的通路),失败再直连。
|
||||
*/
|
||||
export async function syncGeoFile(
|
||||
env: Env,
|
||||
configKey: string,
|
||||
geoxUrl: Record<string, unknown>,
|
||||
proxy: string | null,
|
||||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
const spec = GEO_FILES.find((g) => g.configKey === configKey)
|
||||
if (!spec) return { ok: false, message: `未知的 GEO 类型:${configKey}` }
|
||||
const url = urlFor(geoxUrl, configKey)
|
||||
|
||||
let bytes: Uint8Array
|
||||
try {
|
||||
bytes = await download(url, proxy)
|
||||
} catch (viaProxy) {
|
||||
if (!proxy) return { ok: false, message: `下载失败:${String(viaProxy)}` }
|
||||
try {
|
||||
bytes = await download(url, null)
|
||||
} catch (direct) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `经内核代理(${String(viaProxy)})和直连(${String(direct)})都没能下载。`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shapeOk =
|
||||
configKey === 'mmdb' || configKey === 'asn' ? looksLikeMmdb(bytes) : looksLikeGeodata(bytes)
|
||||
if (!shapeOk) {
|
||||
return { ok: false, message: `下载到的内容不像 ${spec.key} 文件,已丢弃,本地文件没有动。` }
|
||||
}
|
||||
|
||||
// 沿用目录里现有的大小写写法,内核就是这样找文件的
|
||||
const entries = await readdir(env.mihomoHome).catch(() => [] as string[])
|
||||
const actual = entries.find((name) => name.toLowerCase() === spec.file.toLowerCase()) ?? spec.file
|
||||
const finalPath = join(env.mihomoHome, actual)
|
||||
const tempPath = `${finalPath}.patchbay-tmp`
|
||||
try {
|
||||
await writeFile(tempPath, bytes)
|
||||
await rename(tempPath, finalPath)
|
||||
} catch (error) {
|
||||
return { ok: false, message: `写入失败:${String(error)}` }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 主机信息与公网 IP 检测。
|
||||
*
|
||||
* Patchbay 服务端和 mihomo 跑在同一台机器上,所以这两件事由它代答:
|
||||
* - 主机指标:mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些
|
||||
* 从 os 模块和 /proc 里读(解析在 shared/host-info.ts,那边有测试)
|
||||
* - 公网 IP 检测:浏览器没法「经由某个入站端口」发请求,服务端可以——把本机
|
||||
* 对应端口设成代理去访问 ip.sb,返回的就是那一路在外面的地址
|
||||
*/
|
||||
import os from 'node:os'
|
||||
import {
|
||||
cpuUsageBetween,
|
||||
parseGateway,
|
||||
parseMemAvailable,
|
||||
parseMemTotal,
|
||||
parseNameservers,
|
||||
} from '../shared/host-info.ts'
|
||||
import type { CpuTimes, ExitProbe, HostInfo } from '../shared/host-info.ts'
|
||||
|
||||
function cpuTimesNow(): CpuTimes {
|
||||
let busy = 0
|
||||
let total = 0
|
||||
for (const cpu of os.cpus()) {
|
||||
const t = cpu.times
|
||||
busy += t.user + t.nice + t.sys + t.irq
|
||||
total += t.user + t.nice + t.sys + t.irq + t.idle
|
||||
}
|
||||
return { busy, total }
|
||||
}
|
||||
|
||||
/** 上一次请求时的 CPU 快照。使用率是两次请求之间的平均,第一次没有可比对象。 */
|
||||
let lastCpu: CpuTimes | null = null
|
||||
|
||||
async function readProc(path: string): Promise<string> {
|
||||
try {
|
||||
return await Bun.file(path).text()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function hostInfo(): Promise<HostInfo> {
|
||||
const now = cpuTimesNow()
|
||||
const cpuUsage = lastCpu ? cpuUsageBetween(lastCpu, now) : null
|
||||
lastCpu = now
|
||||
|
||||
const lanAddresses: Array<{ iface: string; address: string }> = []
|
||||
for (const [iface, addrs] of Object.entries(os.networkInterfaces())) {
|
||||
for (const addr of addrs ?? []) {
|
||||
if (addr.family === 'IPv4' && !addr.internal) lanAddresses.push({ iface, address: addr.address })
|
||||
}
|
||||
}
|
||||
|
||||
const meminfo = await readProc('/proc/meminfo')
|
||||
const load = os.loadavg()[0] ?? 0
|
||||
|
||||
return {
|
||||
cpuUsage,
|
||||
cpuCount: os.cpus().length,
|
||||
memTotal: parseMemTotal(meminfo) ?? os.totalmem(),
|
||||
memAvailable: parseMemAvailable(meminfo) ?? os.freemem(),
|
||||
// loadavg 在 Windows 上恒为 0,那不是「负载为零」而是「没有这个概念」
|
||||
load1: process.platform === 'win32' ? null : load,
|
||||
uptimeSeconds: os.uptime(),
|
||||
platform: process.platform === 'linux' ? `Linux ${os.release()}` : `${os.type()} ${os.release()}`,
|
||||
release: os.release(),
|
||||
arch: os.arch(),
|
||||
lanAddresses,
|
||||
gateway: parseGateway(await readProc('/proc/net/route')),
|
||||
nameservers: parseNameservers(await readProc('/etc/resolv.conf')),
|
||||
}
|
||||
}
|
||||
|
||||
/** 公网 IP 检测问谁。ip.sb 的这个接口返回 JSON,带 IP 和归属地。 */
|
||||
const EXIT_URL = 'https://api.ip.sb/geoip'
|
||||
|
||||
/**
|
||||
* 经由本机某个入站端口探测公网 IP。只对说 HTTP 的入站有效(http/mixed),
|
||||
* 纯 socks 端口这条路走不通。
|
||||
*/
|
||||
export async function probeExit(port: number): Promise<ExitProbe> {
|
||||
const started = Date.now()
|
||||
try {
|
||||
const res = await fetch(EXIT_URL, {
|
||||
proxy: `http://127.0.0.1:${String(port)}`,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!res.ok) return { ok: false, message: `探测目标返回 HTTP ${String(res.status)}` }
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
return {
|
||||
ok: true,
|
||||
ip: typeof body.ip === 'string' ? body.ip : '',
|
||||
country: typeof body.country === 'string' ? body.country : '',
|
||||
countryCode: typeof body.country_code === 'string' ? body.country_code : '',
|
||||
ms: Date.now() - started,
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -8,7 +8,7 @@ import { readEnv } from './env.ts'
|
||||
import { resolveToken } from './auth.ts'
|
||||
import { createRouter } from './routes.ts'
|
||||
import { readConfig } from './config-store.ts'
|
||||
import { kernelVersion } from './kernel.ts'
|
||||
import { kernelVersion, warmUpKernelBinary } from './kernel.ts'
|
||||
|
||||
const env = readEnv()
|
||||
const { token, source } = await resolveToken(env)
|
||||
@@ -29,6 +29,8 @@ try {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
warmUpKernelBinary(env)
|
||||
|
||||
const kernel = await kernelVersion(env)
|
||||
console.log(
|
||||
kernel.online
|
||||
@@ -40,6 +42,11 @@ const handle = createRouter(env, token)
|
||||
const server = Bun.serve({
|
||||
hostname: env.host,
|
||||
port: env.port,
|
||||
// Bun 默认 10 秒空闲就掐连接,而透传的内核日志流在没有日志产生时一个字节都不发
|
||||
// ——连响应头都没有(内核源码 hub/route/server.go:510-534,头随第一行日志才写出)。
|
||||
// 被掐的连接经 vite 代理会变成无响应体的 500,界面上是一句指不到病根的「请求失败」。
|
||||
// 这是只监听本机的工具,不设空闲超时没有被拖死的风险
|
||||
idleTimeout: 0,
|
||||
fetch: handle,
|
||||
})
|
||||
|
||||
|
||||
+105
-3
@@ -50,6 +50,24 @@ export async function validateConfigFile(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时先把内核二进制跑一次,什么都不做。
|
||||
*
|
||||
* `mihomo -t` 的第一次调用要 4 秒,之后 55 毫秒(在目标机器那种规格上实测差距只会更大)。
|
||||
* 差的是把这个几十兆的可执行文件读进系统文件缓存的时间,跟配置内容无关。现在只有内核
|
||||
* 不在线时保存才走 `mihomo -t`,而那正是用户最着急的场景,这 4 秒不该落在他头上。
|
||||
*
|
||||
* 失败不用管:内核二进制不可用这件事,第一次真正校验时会带着完整错误信息报出来。
|
||||
*/
|
||||
export function warmUpKernelBinary(env: Env): void {
|
||||
try {
|
||||
const proc = Bun.spawn([env.mihomoBin, '-v'], { stdout: 'ignore', stderr: 'ignore' })
|
||||
void proc.exited
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
|
||||
/** 带上认证头访问管理接口。 */
|
||||
async function callApi(env: Env, path: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(`${env.mihomoApi}${path}`, {
|
||||
@@ -77,6 +95,10 @@ export async function kernelVersion(env: Env): Promise<{ online: boolean; versio
|
||||
/**
|
||||
* 让内核重新加载配置文件,不重启进程。
|
||||
*
|
||||
* 这一步同时就是校验:内核先完整解析,解析失败什么都不应用并返回 400,响应体是解析
|
||||
* 错误原文(内核源码 hub/route/configs.go:430-438)。落盘链路在内核在线时靠它把关,
|
||||
* 不再单独跑 `mihomo -t`。
|
||||
*
|
||||
* `force=true` 意味着连端口这类需要重开监听的改动也一并生效——正是本项目改入站时需要的。
|
||||
* 注意 `path` 是**内核所在机器上的路径**:部署时 Patchbay 和内核在同一台机器上所以没有问题,
|
||||
* 但开发时如果 Patchbay 在本机、内核在远端,这个调用不会加载到本地的文件。
|
||||
@@ -95,7 +117,87 @@ export async function reloadConfig(env: Env, configPath: string): Promise<Valida
|
||||
}
|
||||
}
|
||||
|
||||
/** 透传一个管理接口的 GET 请求,用于运行时数据(线路、连接、日志等)。 */
|
||||
export async function proxyGet(env: Env, path: string): Promise<Response> {
|
||||
return callApi(env, path, { signal: AbortSignal.timeout(15_000) })
|
||||
/**
|
||||
* 切换某个策略组当前选中哪个成员。
|
||||
*
|
||||
* 这是运行时状态,不写配置文件——所以不走 config-store 那条保护链路,也不需要备份和还原。
|
||||
* 内核重启后会不会记住,取决于配置里的 profile.store-selected。
|
||||
*/
|
||||
export async function selectProxy(
|
||||
env: Env,
|
||||
group: string,
|
||||
member: string,
|
||||
): Promise<ValidateResult> {
|
||||
try {
|
||||
const res = await callApi(env, `/proxies/${encodeURIComponent(group)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name: member }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (res.ok) return { ok: true, message: '' }
|
||||
return { ok: false, message: (await res.text()) || `管理接口返回 ${String(res.status)}` }
|
||||
} catch (error) {
|
||||
return { ok: false, message: `连不上管理接口:${String(error)}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 让内核重启进程(POST /restart,内核源码 hub/route/restart.go:20-43)。
|
||||
*
|
||||
* 内核先回 {"status":"ok"} 再自我 exec。Linux 上用的是 syscall.Exec,PID 不变,
|
||||
* systemd 托管不受影响;策略组的选中靠 profile.store-selected 恢复。
|
||||
*/
|
||||
export async function restartKernel(env: Env): Promise<ValidateResult> {
|
||||
try {
|
||||
const res = await callApi(env, '/restart', {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (res.ok) return { ok: true, message: '' }
|
||||
return { ok: false, message: (await res.text()) || `管理接口返回 ${String(res.status)}` }
|
||||
} catch (error) {
|
||||
return { ok: false, message: `连不上管理接口:${String(error)}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 让内核更新 GEO 数据库(POST /configs/geo,内核源码 hub/route/configs.go:30,442-452)。
|
||||
* 同步接口,下载完才回话,超时要给足——GEO 文件几十兆、走的还是外网。
|
||||
*/
|
||||
export async function updateGeoDatabases(env: Env): Promise<ValidateResult> {
|
||||
try {
|
||||
const res = await callApi(env, '/configs/geo', {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(300_000),
|
||||
})
|
||||
if (res.ok) return { ok: true, message: '' }
|
||||
return { ok: false, message: (await res.text()) || `管理接口返回 ${String(res.status)}` }
|
||||
} catch (error) {
|
||||
return { ok: false, message: `连不上管理接口:${String(error)}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 透传一个管理接口的 GET 请求。
|
||||
*
|
||||
* `stream` 为真时不设超时:流量、内存、日志这三个接口不升级 WebSocket 时会退化成
|
||||
* 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于
|
||||
* 每隔一会儿就把连接掐断一次。
|
||||
*/
|
||||
export async function proxyGet(
|
||||
env: Env,
|
||||
path: string,
|
||||
stream = false,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<Response> {
|
||||
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(timeoutMs) })
|
||||
}
|
||||
|
||||
/** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */
|
||||
export async function proxyWrite(
|
||||
env: Env,
|
||||
method: 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
): Promise<Response> {
|
||||
return callApi(env, path, { method, signal: AbortSignal.timeout(30_000) })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { createRouter } from './routes.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/**
|
||||
* 这些用例只走认证和白名单两段判断,都在请求碰到内核或文件系统之前就返回了,
|
||||
* 所以不需要内核在线,也不需要配置文件存在。指向一个不可能有人监听的端口,
|
||||
* 万一哪天有人把判断顺序改错了、请求真的漏到了下游,测试会因为连不上而失败——
|
||||
* 这正是我们想要的信号。
|
||||
*/
|
||||
const env: Env = {
|
||||
mihomoApi: 'http://127.0.0.1:1',
|
||||
mihomoSecret: 'x',
|
||||
configPath: '/nonexistent/config.yaml',
|
||||
mihomoBin: '/nonexistent/mihomo',
|
||||
mihomoHome: '/nonexistent',
|
||||
host: '127.0.0.1',
|
||||
port: 7580,
|
||||
}
|
||||
|
||||
const TOKEN = 'test-token-0123456789'
|
||||
const handle = createRouter(env, TOKEN)
|
||||
|
||||
const get = (path: string, token: string | null = TOKEN) =>
|
||||
handle(
|
||||
new Request(`http://127.0.0.1:7580${path}`, {
|
||||
headers: token === null ? {} : { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
)
|
||||
|
||||
describe('认证挡在所有接口前面', () => {
|
||||
it('没带令牌一律 401', async () => {
|
||||
expect((await get('/api/config', null)).status).toBe(401)
|
||||
expect((await get('/api/kernel', null)).status).toBe(401)
|
||||
expect((await get('/api/meta/listener-fields', null)).status).toBe(401)
|
||||
})
|
||||
|
||||
it('令牌不对一律 401', async () => {
|
||||
expect((await get('/api/config', 'wrong')).status).toBe(401)
|
||||
})
|
||||
|
||||
it('401 的响应带上令牌在哪儿找', async () => {
|
||||
const body = (await (await get('/api/config', null)).json()) as { hint?: string }
|
||||
expect(body.hint).toContain('.patchbay-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('内核接口的透传白名单', () => {
|
||||
it('放行只读的运行时数据', async () => {
|
||||
// 连不上内核会抛,但那说明请求已经通过了白名单——这里只关心没被 403 挡下
|
||||
for (const path of ['/proxies', '/connections', '/rules', '/providers/proxies']) {
|
||||
const res = await get(`/api/kernel${path}`)
|
||||
expect(res.status).not.toBe(403)
|
||||
}
|
||||
})
|
||||
|
||||
it('放行延迟测试和订阅健康检查', async () => {
|
||||
const paths = [
|
||||
'/proxies/AUTO/delay?url=x&timeout=1',
|
||||
'/group/PROXY/delay?url=x&timeout=1',
|
||||
'/providers/proxies/subs/healthcheck?url=x&timeout=1',
|
||||
'/providers/proxies/subs/%5Bvless%5DGomami/healthcheck?url=x&timeout=1',
|
||||
]
|
||||
for (const path of paths) {
|
||||
expect((await get(`/api/kernel${path}`)).status).not.toBe(403)
|
||||
}
|
||||
})
|
||||
|
||||
it('挡住不在白名单里的路径', async () => {
|
||||
// 白名单而不是黑名单:多开一个口子必须是有意的
|
||||
for (const path of ['/configs/geo', '/cache/fakeip/flush', '/restart', '/upgrade']) {
|
||||
const res = await get(`/api/kernel${path}`)
|
||||
expect(res.status).toBe(403)
|
||||
}
|
||||
})
|
||||
|
||||
it('挡住形似但不是健康检查的路径', async () => {
|
||||
// 正则写松了会把 /providers/proxies/subs/anything 也放过去
|
||||
expect((await get('/api/kernel/providers/proxies/subs/a/b/healthcheck')).status).toBe(403)
|
||||
expect((await get('/api/kernel/proxies/AUTO/delay/extra')).status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('写操作的白名单', () => {
|
||||
const write = (method: string, path: string) =>
|
||||
handle(
|
||||
new Request(`http://127.0.0.1:7580/api/kernel${path}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${TOKEN}` },
|
||||
}),
|
||||
)
|
||||
|
||||
it('放行断开连接和刷新订阅', async () => {
|
||||
expect((await write('DELETE', '/connections')).status).not.toBe(403)
|
||||
expect((await write('DELETE', '/connections/abc-123')).status).not.toBe(403)
|
||||
expect((await write('PUT', '/providers/proxies/subs')).status).not.toBe(403)
|
||||
})
|
||||
|
||||
it('挡住没列出来的写操作', async () => {
|
||||
// 重启内核、切换配置这类动作不该从这里走
|
||||
expect((await write('PUT', '/configs')).status).toBe(403)
|
||||
expect((await write('DELETE', '/cache/fakeip/flush')).status).toBe(403)
|
||||
expect((await write('PUT', '/providers/proxies/subs/healthcheck')).status).toBe(403)
|
||||
})
|
||||
|
||||
it('方法对不上也挡住', async () => {
|
||||
// 断开连接是 DELETE,刷新订阅是 PUT,反过来都不行
|
||||
expect((await write('PUT', '/connections')).status).toBe(403)
|
||||
expect((await write('DELETE', '/providers/proxies/subs')).status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('其它', () => {
|
||||
it('不存在的接口回 404 并带上说明', async () => {
|
||||
const res = await get('/api/nope')
|
||||
expect(res.status).toBe(404)
|
||||
expect(((await res.json()) as { message?: string }).message).toBeTruthy()
|
||||
})
|
||||
|
||||
it('静态数据不需要内核', async () => {
|
||||
const res = await get('/api/meta/listener-fields')
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { types: Record<string, unknown> }
|
||||
expect(Object.keys(body.types).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('前端页面不要令牌——它只是空壳,数据都要带令牌才拿得到', async () => {
|
||||
expect((await get('/', null)).status).not.toBe(401)
|
||||
})
|
||||
})
|
||||
+280
-14
@@ -9,13 +9,70 @@
|
||||
* 透传只开放 GET。写操作(切换节点、关连接)属于运行时面板的功能,等做到那一步再单独开,
|
||||
* 现在不留一个「什么都能转发」的口子。
|
||||
*/
|
||||
import { resolve, sep } from 'node:path'
|
||||
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
|
||||
import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
||||
import { kernelVersion, proxyGet } from './kernel.ts'
|
||||
import { isProviderFilePath } from '../shared/provider-files.ts'
|
||||
import {
|
||||
applyConfig,
|
||||
backupDiff,
|
||||
importConfig,
|
||||
listBackups,
|
||||
listRedo,
|
||||
readConfig,
|
||||
redoConfig,
|
||||
restoreBackup,
|
||||
undoConfig,
|
||||
} from './config-store.ts'
|
||||
import { geoStatus, syncGeoFile } from './geo.ts'
|
||||
import { hostInfo, probeExit } from './host.ts'
|
||||
import {
|
||||
kernelVersion,
|
||||
proxyGet,
|
||||
proxyWrite,
|
||||
reloadConfig,
|
||||
restartKernel,
|
||||
selectProxy,
|
||||
updateGeoDatabases,
|
||||
} from './kernel.ts'
|
||||
import { tokenFromRequest, tokenMatches } from './auth.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/**
|
||||
* 延迟测试。名字里可能有斜杠和中文,所以按前缀匹配而不是列进上面那个精确白名单。
|
||||
* 它是 GET 但会真的发起网络连接,属于「读」里比较重的一类,单独列出来提醒这一点。
|
||||
*/
|
||||
const DELAY_PATH = /^\/(proxies|group)\/.+\/delay$/
|
||||
|
||||
/**
|
||||
* 订阅线路的健康检查。
|
||||
*
|
||||
* 订阅线路不在内核的全局名字表里,所以 /proxies/{名字}/delay 对它们一律 404——
|
||||
* 要测只能从它所属的订阅进去。整条订阅测用 {订阅}/healthcheck,单条用 {订阅}/{线路}/healthcheck。
|
||||
*/
|
||||
const HEALTHCHECK_PATH = /^\/providers\/proxies\/[^/]+(\/[^/]+)?\/healthcheck$/
|
||||
|
||||
/**
|
||||
* 会持续吐数据的接口。透传它们时不能设超时。
|
||||
* 内核在没有 WebSocket 升级时把这三个退化成分块的 HTTP 流,一行一个 JSON。
|
||||
*/
|
||||
const STREAM_PATHS = new Set(['/traffic', '/memory', '/logs'])
|
||||
|
||||
/**
|
||||
* 允许透传的写操作。
|
||||
*
|
||||
* 每一条都是「只改内核运行状态、不碰配置文件」的动作,所以不走落盘那条保护链路。
|
||||
* 仍然是白名单:能写的东西必须一条条列出来。
|
||||
*/
|
||||
const WRITABLE: Array<{ method: 'PUT' | 'DELETE'; pattern: RegExp }> = [
|
||||
// 断开某条连接,或者全部断开
|
||||
{ method: 'DELETE', pattern: /^\/connections(\/[^/]+)?$/ },
|
||||
// 让内核重新拉取某个订阅
|
||||
{ method: 'PUT', pattern: /^\/providers\/proxies\/[^/]+$/ },
|
||||
// 让内核重新拉取某个规则组
|
||||
{ method: 'PUT', pattern: /^\/providers\/rules\/[^/]+$/ },
|
||||
]
|
||||
|
||||
/** 允许透传的管理接口路径。白名单而不是黑名单——多开一个口子要有意为之。 */
|
||||
const READABLE_KERNEL_PATHS = new Set([
|
||||
'/version',
|
||||
@@ -26,6 +83,8 @@ const READABLE_KERNEL_PATHS = new Set([
|
||||
'/providers/rules',
|
||||
'/connections',
|
||||
'/memory',
|
||||
'/traffic',
|
||||
'/logs',
|
||||
])
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
@@ -47,29 +106,74 @@ const WEB_DIST = new URL('../web-dist/', import.meta.url).pathname.replace(/^\/(
|
||||
|
||||
/**
|
||||
* 静态文件不需要令牌——它们只是一个空壳,所有数据都要带令牌才拿得到。
|
||||
* 单页应用的路由由前端接管,所以找不到的路径一律回 index.html。
|
||||
*
|
||||
* 单页应用的路由由前端接管,所以找不到的路径回 index.html。但这条**不能**用在
|
||||
* 带扩展名的路径上:资源文件是带哈希的,每次打包都换名字,浏览器要是缓存了旧的
|
||||
* index.html,它引用的那个 .js 就已经不存在了。这时候回 index.html 等于把一份 HTML
|
||||
* 当作 JavaScript 交给浏览器,报出来的是「MIME 类型不对」——一句完全指不到病根的错误。
|
||||
* 老老实实回 404,浏览器才会重新去取 index.html。
|
||||
*/
|
||||
const HAS_EXTENSION = /\.[a-z0-9]+$/i
|
||||
|
||||
async function serveStatic(path: string): Promise<Response> {
|
||||
const shellHeaders = {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
// 空壳不缓存,否则打包后旧的 index.html 会一直引用已经不存在的资源
|
||||
'Cache-Control': 'no-cache',
|
||||
}
|
||||
|
||||
const target = Bun.file(WEB_DIST + (path === '/' ? 'index.html' : path.slice(1)))
|
||||
if (await target.exists()) return new Response(target)
|
||||
if (await target.exists()) {
|
||||
return path === '/' ? new Response(target, { headers: shellHeaders }) : new Response(target)
|
||||
}
|
||||
|
||||
if (HAS_EXTENSION.test(path)) {
|
||||
return new Response(`找不到 ${path}`, {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
})
|
||||
}
|
||||
|
||||
const shell = Bun.file(`${WEB_DIST}index.html`)
|
||||
if (await shell.exists()) return new Response(shell)
|
||||
return new Response('前端还没打包。先运行 bun run build,或者开发时用 bun run dev:web。', {
|
||||
if (await shell.exists()) return new Response(shell, { headers: shellHeaders })
|
||||
return new Response('前端还没打包。运行 bun run build,或者开发时用 bun run dev:web。', {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
})
|
||||
}
|
||||
|
||||
export function createRouter(env: Env, token: string) {
|
||||
const route = makeRoute(env, token)
|
||||
|
||||
/**
|
||||
* 兜底。没有它的话,请求体不是合法 JSON、或者配置文件在运行中被删,都会抛出一个
|
||||
* 没有响应体的 500,前端只能显示「请求失败(HTTP 500)」——这条链路的全部立意是
|
||||
* 出事时把用户救回来,而出事时给出的偏偏是最没信息量的一句话。
|
||||
*/
|
||||
return async function handle(req: Request): Promise<Response> {
|
||||
try {
|
||||
return await route(req)
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
message: String(error),
|
||||
hint: '这是 Patchbay 自身的故障,配置文件没有被改动。',
|
||||
},
|
||||
500,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeRoute(env: Env, token: string) {
|
||||
return async function route(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url)
|
||||
const path = url.pathname
|
||||
|
||||
if (!path.startsWith('/api/')) return serveStatic(path)
|
||||
|
||||
if (!tokenMatches(token, tokenFromRequest(req))) {
|
||||
return json({ error: '令牌不对。它在配置文件旁边的 .patchbay-token 里。' }, 401)
|
||||
return json({ message: '令牌不正确', hint: '它在服务端配置文件的同级目录 .patchbay-token 里。' }, 401)
|
||||
}
|
||||
|
||||
// ── 配置 ────────────────────────────────────────────
|
||||
@@ -81,14 +185,60 @@ export function createRouter(env: Env, token: string) {
|
||||
if (path === '/api/config' && req.method === 'PUT') {
|
||||
const body = (await req.json()) as { data?: ConfigObject; baseRevision?: string }
|
||||
if (!body.data || typeof body.data !== 'object') {
|
||||
return json({ error: '请求里没有 data 字段' }, 400)
|
||||
return json({ message: '请求缺少 data 字段' }, 400)
|
||||
}
|
||||
const result = await applyConfig(env, body.data, body.baseRevision)
|
||||
// merge:界面上的连续保存在时间窗内合并成一条历史。还原、导入不走这里
|
||||
const result = await applyConfig(env, body.data, body.baseRevision, { merge: true })
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
if (path === '/api/config/backups' && req.method === 'GET') {
|
||||
return json({ backups: await listBackups(env) })
|
||||
// redo 是重做栈深度,侧栏用它决定重做按钮亮不亮
|
||||
return json({ backups: await listBackups(env), redo: (await listRedo(env)).length })
|
||||
}
|
||||
|
||||
// 撤回/重做。走 config-store 的保护链路,备份链的进出由它们自己管
|
||||
if (path === '/api/config/undo' && req.method === 'POST') {
|
||||
const result = await undoConfig(env)
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
if (path === '/api/config/redo' && req.method === 'POST') {
|
||||
const result = await redoConfig(env)
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
// 配置文件原文,给「下载当前配置」用。带 attachment 让浏览器按下载处理
|
||||
if (path === '/api/config/raw' && req.method === 'GET') {
|
||||
const file = Bun.file(env.configPath)
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
'Content-Type': 'text/yaml; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="config.yaml"',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 上传的 YAML 替换当前配置(mode=override 整份,compatible 只搬接管段落)。
|
||||
// 走 config-store 完整的保护链路
|
||||
if (path === '/api/config/import' && req.method === 'POST') {
|
||||
const body = (await req.json()) as { content?: string; mode?: string }
|
||||
if (typeof body.content !== 'string' || body.content.trim() === '') {
|
||||
return json({ message: '请求缺少 content 字段' }, 400)
|
||||
}
|
||||
if (body.mode !== 'override' && body.mode !== 'compatible') {
|
||||
return json({ message: 'mode 必须是 override 或 compatible' }, 400)
|
||||
}
|
||||
const result = await importConfig(env, body.content, body.mode)
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
// 一条历史记录的对比原文:这份备份(改动前)和它之后的状态(改动后)
|
||||
const diffMatch = /^\/api\/config\/backups\/([^/]+)\/diff$/.exec(path)
|
||||
if (diffMatch && req.method === 'GET') {
|
||||
const pair = await backupDiff(env, decodeURIComponent(diffMatch[1]!))
|
||||
if (!pair) return json({ message: '没有这个备份', hint: '刷新一下历史记录再试。' }, 404)
|
||||
return json(pair)
|
||||
}
|
||||
|
||||
const restoreMatch = /^\/api\/config\/backups\/([^/]+)\/restore$/.exec(path)
|
||||
@@ -98,17 +248,133 @@ export function createRouter(env: Env, token: string) {
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
// ── 清单文件 ────────────────────────────────────────
|
||||
// 从界面上传的订阅/规则清单,写进内核工作目录的 providers/ 下。
|
||||
// 路径白名单校验 + 解析后确认没出工作目录,两道都过才落盘
|
||||
if (path === '/api/provider-files' && req.method === 'POST') {
|
||||
const body = (await req.json()) as { path?: string; content?: string }
|
||||
if (typeof body.path !== 'string' || typeof body.content !== 'string') {
|
||||
return json({ message: '请求缺少 path 或 content 字段' }, 400)
|
||||
}
|
||||
if (!isProviderFilePath(body.path)) {
|
||||
return json({ message: `不允许写入该路径:${body.path}` }, 400)
|
||||
}
|
||||
const home = resolve(env.mihomoHome)
|
||||
const target = resolve(home, body.path)
|
||||
if (!target.startsWith(home + sep)) {
|
||||
return json({ message: `不允许写入该路径:${body.path}` }, 400)
|
||||
}
|
||||
await Bun.write(target, body.content)
|
||||
return json({ ok: true })
|
||||
}
|
||||
|
||||
// ── 主机 ────────────────────────────────────────────
|
||||
// 服务端和内核在同一台机器上,所以主机指标由 Patchbay 自己代答
|
||||
if (path === '/api/host' && req.method === 'GET') {
|
||||
return json(await hostInfo())
|
||||
}
|
||||
|
||||
// 经由某个入站端口探测公网 IP。GET 但会发起真实的外网请求,和延迟测试同一类
|
||||
if (path === '/api/host/exit' && req.method === 'GET') {
|
||||
const port = Number(url.searchParams.get('port'))
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return json({ message: 'port 参数必须是 1-65535 的端口号' }, 400)
|
||||
}
|
||||
return json(await probeExit(port))
|
||||
}
|
||||
|
||||
// ── 内核 ────────────────────────────────────────────
|
||||
if (path === '/api/kernel' && req.method === 'GET') {
|
||||
return json(await kernelVersion(env))
|
||||
}
|
||||
|
||||
// 切换某个策略组当前选中哪个成员。这是白名单里唯一的写操作,单独开而不是放开整个透传:
|
||||
// 它只改内核的运行时状态,不碰配置文件,而且是这个工具最日常的动作。
|
||||
const selectMatch = /^\/api\/kernel\/proxies\/([^/]+)$/.exec(path)
|
||||
if (selectMatch && req.method === 'PUT') {
|
||||
const group = decodeURIComponent(selectMatch[1]!)
|
||||
const body = (await req.json()) as { name?: string }
|
||||
if (!body.name) return json({ message: '请求缺少 name 字段' }, 400)
|
||||
const upstream = await selectProxy(env, group, body.name)
|
||||
return upstream.ok
|
||||
? json({ ok: true })
|
||||
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
|
||||
}
|
||||
|
||||
// 让内核重新加载磁盘上的配置文件。给 SSH 手改过配置的场景用;运行时动作
|
||||
if (path === '/api/kernel/reload' && req.method === 'POST') {
|
||||
const upstream = await reloadConfig(env, env.configPath)
|
||||
return upstream.ok
|
||||
? json({ ok: true })
|
||||
: json({ message: upstream.message || '内核重载失败' }, 502)
|
||||
}
|
||||
|
||||
// GEO 数据库文件的本地状态(大小、更新时间)和生效中的下载地址
|
||||
if (path === '/api/geo' && req.method === 'GET') {
|
||||
const snapshot = await readConfig(env)
|
||||
const geox = (snapshot.data['geox-url'] ?? {}) as Record<string, unknown>
|
||||
return json({ files: await geoStatus(env, geox) })
|
||||
}
|
||||
|
||||
// 单个 GEO 文件的下载。服务端自己下(内核只有全量更新的口子),重启后生效
|
||||
if (path === '/api/geo/sync' && req.method === 'POST') {
|
||||
const body = (await req.json()) as { key?: string }
|
||||
if (typeof body.key !== 'string') return json({ message: '请求缺少 key 字段' }, 400)
|
||||
const snapshot = await readConfig(env)
|
||||
const geox = (snapshot.data['geox-url'] ?? {}) as Record<string, unknown>
|
||||
// 内核在线且开着老式 http 入口时借它的通路下载,直连 GitHub 不一定通
|
||||
const port = [snapshot.data['mixed-port'], snapshot.data.port].find(
|
||||
(p): p is number => typeof p === 'number' && p > 0,
|
||||
)
|
||||
const kernelUp = port !== undefined && (await kernelVersion(env)).online
|
||||
const proxy = kernelUp ? `http://127.0.0.1:${String(port)}` : null
|
||||
const result = await syncGeoFile(env, body.key, geox, proxy)
|
||||
return result.ok ? json({ ok: true }) : json({ message: result.message }, 502)
|
||||
}
|
||||
|
||||
// 更新 GEO 数据库。运行时动作;同步接口,透传层给了长超时
|
||||
if (path === '/api/kernel/geo' && req.method === 'POST') {
|
||||
const upstream = await updateGeoDatabases(env)
|
||||
return upstream.ok
|
||||
? json({ ok: true })
|
||||
: json({ message: upstream.message || '内核更新 GEO 数据库失败' }, 502)
|
||||
}
|
||||
|
||||
// 重启内核进程。运行时动作,不碰配置文件;和其它写操作一样单独点名,不开通用口子
|
||||
if (path === '/api/kernel/restart' && req.method === 'POST') {
|
||||
const upstream = await restartKernel(env)
|
||||
return upstream.ok
|
||||
? json({ ok: true })
|
||||
: json({ message: upstream.message || '内核拒绝了重启请求' }, 502)
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/kernel/') && (req.method === 'PUT' || req.method === 'DELETE')) {
|
||||
const target = path.slice('/api/kernel'.length)
|
||||
const rule = WRITABLE.find((w) => w.method === req.method && w.pattern.test(target))
|
||||
if (!rule) return json({ message: `不允许写入该路径:${target}` }, 403)
|
||||
const upstream = await proxyWrite(env, rule.method, target + url.search)
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/kernel/') && req.method === 'GET') {
|
||||
const target = path.slice('/api/kernel'.length)
|
||||
if (!READABLE_KERNEL_PATHS.has(target)) {
|
||||
return json({ error: `不允许透传这个路径:${target}` }, 403)
|
||||
const allowed =
|
||||
READABLE_KERNEL_PATHS.has(target) || DELAY_PATH.test(target) || HEALTHCHECK_PATH.test(target)
|
||||
if (!allowed) {
|
||||
return json({ message: `不允许访问该路径:${target}` }, 403)
|
||||
}
|
||||
const upstream = await proxyGet(env, target + url.search)
|
||||
// 健康检查是同步的:内核测完才回话,一批 10 条、每条最多 5 秒
|
||||
// (内核源码 adapter/provider/healthcheck.go:132、181、201),
|
||||
// 整条订阅测且线路多又都测不通时会远超默认的 15 秒
|
||||
const upstream = await proxyGet(
|
||||
env,
|
||||
target + url.search,
|
||||
STREAM_PATHS.has(target),
|
||||
HEALTHCHECK_PATH.test(target) ? 120_000 : undefined,
|
||||
)
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
|
||||
@@ -120,6 +386,6 @@ export function createRouter(env: Env, token: string) {
|
||||
return json(listenerFields)
|
||||
}
|
||||
|
||||
return json({ error: '没有这个接口' }, 404)
|
||||
return json({ message: '没有这个接口' }, 404)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
import { backupTime } from './backups.ts'
|
||||
|
||||
test('标准备份名排成可读时间', () => {
|
||||
expect(backupTime('config.yaml.bak-20260811-031000')).toBe('2026-08-11 03:10:00')
|
||||
})
|
||||
|
||||
test('配置文件名不同也能解析,只看结尾的时间戳', () => {
|
||||
expect(backupTime('mihomo.yml.bak-20260101-235959')).toBe('2026-01-01 23:59:59')
|
||||
})
|
||||
|
||||
test('不合预期的名字返回 null', () => {
|
||||
expect(backupTime('config.yaml.bak')).toBeNull()
|
||||
expect(backupTime('config.yaml.bak-2026081-031000')).toBeNull()
|
||||
expect(backupTime('notes.txt')).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 备份文件名里的时间戳。
|
||||
*
|
||||
* 名字由服务端生成(server/config-store.ts 的 backupName),形如
|
||||
* `config.yaml.bak-20260811-031000`,时间戳是服务端的本地时间。
|
||||
*/
|
||||
|
||||
const STAMP = /\.bak-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/
|
||||
|
||||
/** 把备份文件名排成可读时间。名字不合预期时返回 null,调用方退回显示原名。 */
|
||||
export function backupTime(name: string): string | null {
|
||||
const m = STAMP.exec(name)
|
||||
if (!m) return null
|
||||
return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}`
|
||||
}
|
||||
|
||||
/** 备份文件名里的时间戳解析成 Date(服务端本地时间)。名字不合预期时返回 null。 */
|
||||
export function backupDate(name: string): Date | null {
|
||||
const m = STAMP.exec(name)
|
||||
if (!m) return null
|
||||
return new Date(
|
||||
Number(m[1]),
|
||||
Number(m[2]) - 1,
|
||||
Number(m[3]),
|
||||
Number(m[4]),
|
||||
Number(m[5]),
|
||||
Number(m[6]),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { MARKER_KEY, hasValidMarker, withMarker } from './config-marker.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
describe('配置标记', () => {
|
||||
test('打上标记后校验通过,其余键不动', () => {
|
||||
const data: ConfigObject = { 'mixed-port': 7890 }
|
||||
const marked = withMarker(data)
|
||||
expect(hasValidMarker(marked)).toBe(true)
|
||||
expect(marked['mixed-port']).toBe(7890)
|
||||
})
|
||||
|
||||
test('没有标记、标记形状不对、sig 对不上都算无效', () => {
|
||||
expect(hasValidMarker({})).toBe(false)
|
||||
expect(hasValidMarker({ [MARKER_KEY]: 'yes' })).toBe(false)
|
||||
expect(hasValidMarker({ [MARKER_KEY]: { v: 1, sig: 'deadbeef' } })).toBe(false)
|
||||
})
|
||||
|
||||
test('打两次标记结果一样(落盘链路的内容不变检查靠它短路)', () => {
|
||||
const once = withMarker({})
|
||||
expect(withMarker(once)).toEqual(once)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 配置文件里的 Patchbay 标记。
|
||||
*
|
||||
* 每次落盘都在顶层写一个 `patchbay:` 键(内核对未知顶层键不报错:解析用的
|
||||
* `yaml.Unmarshal` 没开严格模式,config/config.go:612)。上传配置文件时靠它
|
||||
* 区分「Patchbay 管理过的配置」和随便一份 mihomo 配置——前者直接还原,
|
||||
* 后者提示走导入。
|
||||
*
|
||||
* sig 是盐加版本号的哈希,回答的是「这个标记是不是 Patchbay 写的」,不是防伪
|
||||
* ——在用户自己的机器上没有对手。刻意不对配置内容做哈希:这个项目只接管部分
|
||||
* 段落,手改其余段落是正常用法(决策记录 0003),内容哈希会把合法的手改
|
||||
* 判成无效。
|
||||
*/
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
export const MARKER_KEY = 'patchbay'
|
||||
|
||||
const VERSION = 1
|
||||
const SALT = 'patchbay-config-salt'
|
||||
|
||||
/** FNV-1a。只做标记指纹,不是安全用途。 */
|
||||
function fnv(text: string): string {
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i)
|
||||
h = Math.imul(h, 0x01000193)
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
const sigOf = (version: number): string => fnv(`${SALT}:${String(version)}`)
|
||||
|
||||
/** 给一份配置打上标记。已有的旧标记会被换成当前版本的。 */
|
||||
export function withMarker(data: ConfigObject): ConfigObject {
|
||||
return { ...data, [MARKER_KEY]: { v: VERSION, sig: sigOf(VERSION) } }
|
||||
}
|
||||
|
||||
/** 这份配置带不带有效的 Patchbay 标记。 */
|
||||
export function hasValidMarker(data: ConfigObject): boolean {
|
||||
const marker = data[MARKER_KEY]
|
||||
if (typeof marker !== 'object' || marker === null || Array.isArray(marker)) return false
|
||||
const { v, sig } = marker as Record<string, unknown>
|
||||
return typeof v === 'number' && sig === sigOf(v)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
chainLabel,
|
||||
filterConnections,
|
||||
formatDuration,
|
||||
groupByInbound,
|
||||
hostLabel,
|
||||
ruleLabel,
|
||||
sortConnections,
|
||||
} from './connections.ts'
|
||||
import type { ConnectionItem, ConnectionMeta } from './connections.ts'
|
||||
|
||||
const NOW = Date.parse('2026-08-11T18:40:00+08:00')
|
||||
|
||||
function meta(over: Partial<ConnectionMeta> = {}): ConnectionMeta {
|
||||
return {
|
||||
host: 'example.com',
|
||||
destinationIP: '93.184.216.34',
|
||||
destinationPort: '443',
|
||||
inboundName: 'lxc-web',
|
||||
inboundPort: '17891',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function conn(over: Partial<ConnectionItem> = {}): ConnectionItem {
|
||||
return {
|
||||
id: 'a',
|
||||
upload: 0,
|
||||
download: 0,
|
||||
start: '2026-08-11T18:39:00+08:00',
|
||||
chains: ['[vless]某线路', 'AUTO', 'PROXY'],
|
||||
rule: 'DomainSuffix',
|
||||
rulePayload: 'example.com',
|
||||
metadata: meta(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('主机标签', () => {
|
||||
it('有域名用域名,带端口', () => {
|
||||
expect(hostLabel(meta())).toBe('example.com:443')
|
||||
})
|
||||
|
||||
it('没域名退回 IP', () => {
|
||||
expect(hostLabel(meta({ host: '' }))).toBe('93.184.216.34:443')
|
||||
})
|
||||
|
||||
it('没端口就只给主机', () => {
|
||||
expect(hostLabel(meta({ destinationPort: '' }))).toBe('example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('出站链', () => {
|
||||
it('倒过来显示:最先接触的在左,最终出口在右', () => {
|
||||
expect(chainLabel(['[vless]某线路', 'AUTO', 'PROXY'])).toBe('PROXY → AUTO → [vless]某线路')
|
||||
})
|
||||
|
||||
it('单跳就是直连', () => {
|
||||
expect(chainLabel(['DIRECT'])).toBe('DIRECT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则标签', () => {
|
||||
it('类型和载荷用空格连起来', () => {
|
||||
expect(ruleLabel('DomainSuffix', 'example.com')).toBe('DomainSuffix example.com')
|
||||
})
|
||||
|
||||
it('兜底规则没有载荷', () => {
|
||||
expect(ruleLabel('Match', '')).toBe('Match')
|
||||
})
|
||||
})
|
||||
|
||||
describe('持续时间', () => {
|
||||
it('不满一分钟按秒', () => {
|
||||
expect(formatDuration('2026-08-11T18:39:18+08:00', NOW)).toBe('42 秒')
|
||||
})
|
||||
|
||||
it('满一分钟带分', () => {
|
||||
expect(formatDuration('2026-08-11T18:36:36+08:00', NOW)).toBe('3 分 24 秒')
|
||||
})
|
||||
|
||||
it('满一小时不再给秒', () => {
|
||||
expect(formatDuration('2026-08-11T16:35:00+08:00', NOW)).toBe('2 小时 5 分')
|
||||
})
|
||||
|
||||
it('start 带时区偏移,跨时区解析不能算错', () => {
|
||||
// 同一时刻写成 UTC 和 +08:00,算出来的持续时间必须一样
|
||||
const local = formatDuration('2026-08-11T18:39:00+08:00', NOW)
|
||||
const utc = formatDuration('2026-08-11T10:39:00Z', NOW)
|
||||
expect(local).toBe(utc)
|
||||
})
|
||||
|
||||
it('坏数据返回空串,未来的时间按 0 算', () => {
|
||||
expect(formatDuration('not-a-date', NOW)).toBe('')
|
||||
expect(formatDuration('2026-08-11T19:00:00+08:00', NOW)).toBe('0 秒')
|
||||
})
|
||||
})
|
||||
|
||||
describe('排序', () => {
|
||||
const list = [
|
||||
conn({ id: 'small-new', download: 100, start: '2026-08-11T18:39:50+08:00' }),
|
||||
conn({ id: 'big-new', download: 9000, start: '2026-08-11T18:39:40+08:00' }),
|
||||
conn({ id: 'small-old', download: 50, start: '2026-08-11T18:00:00+08:00' }),
|
||||
]
|
||||
|
||||
it('按下载量降序', () => {
|
||||
expect(sortConnections(list, 'download', NOW).map((c) => c.id)).toEqual([
|
||||
'big-new',
|
||||
'small-new',
|
||||
'small-old',
|
||||
])
|
||||
})
|
||||
|
||||
it('按持续时间降序:活得久的排前面', () => {
|
||||
expect(sortConnections(list, 'duration', NOW).map((c) => c.id)).toEqual([
|
||||
'small-old',
|
||||
'big-new',
|
||||
'small-new',
|
||||
])
|
||||
})
|
||||
|
||||
it('不动原数组', () => {
|
||||
const before = list.map((c) => c.id)
|
||||
sortConnections(list, 'download', NOW)
|
||||
expect(list.map((c) => c.id)).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('筛选', () => {
|
||||
const list = [
|
||||
conn({ id: 'a', metadata: meta({ host: 'speed.cloudflare.com' }) }),
|
||||
conn({ id: 'b', metadata: meta({ host: '', destinationIP: '1.2.3.4' }) }),
|
||||
]
|
||||
|
||||
it('匹配主机名,大小写不敏感', () => {
|
||||
expect(filterConnections(list, 'CLOUDFLARE').map((c) => c.id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('没域名时也能按 IP 搜', () => {
|
||||
expect(filterConnections(list, '1.2').map((c) => c.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('空串不筛,空白也不算条件', () => {
|
||||
expect(filterConnections(list, ' ')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('按入站聚合', () => {
|
||||
it('同一路入站计数、流量都累加,连接多的排前面', () => {
|
||||
const list = [
|
||||
conn({ id: 'a', download: 10, upload: 1 }),
|
||||
conn({ id: 'b', download: 20, upload: 2 }),
|
||||
conn({ id: 'c', metadata: meta({ inboundName: 'lxc-db', inboundPort: '17892' }) }),
|
||||
]
|
||||
expect(groupByInbound(list)).toEqual([
|
||||
{ label: 'lxc-web(17891)', count: 2, upload: 3, download: 30 },
|
||||
{ label: 'lxc-db(17892)', count: 1, upload: 0, download: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('legacy 入站用内核给的内部名,附上端口', () => {
|
||||
const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })]
|
||||
expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)')
|
||||
})
|
||||
|
||||
it('入站名字缺失时用端口凑', () => {
|
||||
const list = [conn({ metadata: meta({ inboundName: '' }) })]
|
||||
expect(groupByInbound(list)[0]?.label).toBe('17891')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 连接页的领域逻辑:内核当前连接的显示、筛选、排序和按入站聚合。
|
||||
*
|
||||
* 类型按 v1.19.29 管理接口 GET /connections 的实际返回写(开发内核实测),
|
||||
* 只保留界面用到的字段。`connections` 在没有连接时是 null 而不是空数组。
|
||||
*/
|
||||
|
||||
/** 一条连接里界面关心的元数据。内核返回的字段远不止这些,用不到的不收。 */
|
||||
export interface ConnectionMeta {
|
||||
host: string
|
||||
destinationIP: string
|
||||
destinationPort: string
|
||||
/** 从哪一路入站进来:命名入站是它的名字,legacy 入站是内核造的 DEFAULT-* */
|
||||
inboundName: string
|
||||
inboundPort: string
|
||||
}
|
||||
|
||||
export interface ConnectionItem {
|
||||
id: string
|
||||
upload: number
|
||||
download: number
|
||||
/** ISO 时间,带时区偏移(内核按本机时区输出) */
|
||||
start: string
|
||||
/** 经过的出站链。第一个是最终出口,最后一个是最先接触的——和直觉相反 */
|
||||
chains: string[]
|
||||
/** 命中的分流规则类型,如 DomainSuffix;兜底是 Match */
|
||||
rule: string
|
||||
rulePayload: string
|
||||
metadata: ConnectionMeta
|
||||
}
|
||||
|
||||
export interface ConnectionSnapshot {
|
||||
downloadTotal: number
|
||||
uploadTotal: number
|
||||
connections: ConnectionItem[] | null
|
||||
}
|
||||
|
||||
/** 这条连接连往哪。有域名显示域名,没有退回 IP,都带端口。 */
|
||||
export function hostLabel(meta: ConnectionMeta): string {
|
||||
const target = meta.host || meta.destinationIP
|
||||
return meta.destinationPort ? `${target}:${meta.destinationPort}` : target
|
||||
}
|
||||
|
||||
/**
|
||||
* 出站链按流量经过的顺序显示:最先接触的在左,最终出口在右。
|
||||
* 内核给的数组恰好相反,所以要倒过来。
|
||||
*/
|
||||
export function chainLabel(chains: string[]): string {
|
||||
return [...chains].reverse().join(' → ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 规则显示成「类型 载荷」,兜底规则没有载荷。
|
||||
* 类型和载荷都是配置文件 rules: 里的原词,不翻译。
|
||||
*/
|
||||
export function ruleLabel(rule: string, payload: string): string {
|
||||
return payload ? `${rule} ${payload}` : rule
|
||||
}
|
||||
|
||||
/** 持续时间。start 解析不出来(坏数据)时返回空串,由界面决定摆什么。 */
|
||||
export function formatDuration(startIso: string, now: number): string {
|
||||
const started = Date.parse(startIso)
|
||||
if (Number.isNaN(started)) return ''
|
||||
const seconds = Math.max(0, Math.floor((now - started) / 1000))
|
||||
if (seconds < 60) return `${String(seconds)} 秒`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${String(minutes)} 分 ${String(seconds % 60)} 秒`
|
||||
return `${String(Math.floor(minutes / 60))} 小时 ${String(minutes % 60)} 分`
|
||||
}
|
||||
|
||||
export type SortKey = 'download' | 'duration'
|
||||
|
||||
/**
|
||||
* 排序。都是降序:下载多的、活得久的排前面。
|
||||
* 持续时间没有现成字段,从 start 现算;排坏的 start 沉底。
|
||||
*/
|
||||
export function sortConnections(
|
||||
list: ConnectionItem[],
|
||||
key: SortKey,
|
||||
now: number,
|
||||
): ConnectionItem[] {
|
||||
const age = (c: ConnectionItem) => {
|
||||
const started = Date.parse(c.start)
|
||||
return Number.isNaN(started) ? -1 : now - started
|
||||
}
|
||||
// 平局按 id 决胜。输入顺序来自内核快照的 map 遍历,本身无序且每秒在变;
|
||||
// 空闲连接的下载量普遍相同,没有决胜键的话行会在指针(和断开按钮)下面跳
|
||||
return [...list].sort((a, b) => {
|
||||
const main = key === 'download' ? b.download - a.download : age(b) - age(a)
|
||||
return main !== 0 ? main : a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
/** 按主机名筛选。大小写不敏感,空串不筛。 */
|
||||
export function filterConnections(list: ConnectionItem[], query: string): ConnectionItem[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return list
|
||||
return list.filter((c) => hostLabel(c.metadata).toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
/** 一路入站当前有多少条连接。 */
|
||||
export interface InboundUse {
|
||||
/** 命名入站显示名字;legacy 入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */
|
||||
label: string
|
||||
count: number
|
||||
upload: number
|
||||
download: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 按入站聚合。这是连接页对本项目的独特价值:每一路入站(每个容器)现在有多少条
|
||||
* 连接、走了多少流量。
|
||||
*/
|
||||
export function groupByInbound(list: ConnectionItem[]): InboundUse[] {
|
||||
const byName = new Map<string, InboundUse>()
|
||||
for (const c of list) {
|
||||
const name = c.metadata.inboundName || c.metadata.inboundPort
|
||||
let entry = byName.get(name)
|
||||
if (!entry) {
|
||||
entry = {
|
||||
label: c.metadata.inboundName
|
||||
? `${c.metadata.inboundName}(${c.metadata.inboundPort})`
|
||||
: c.metadata.inboundPort,
|
||||
count: 0,
|
||||
upload: 0,
|
||||
download: 0,
|
||||
}
|
||||
byName.set(name, entry)
|
||||
}
|
||||
entry.count += 1
|
||||
entry.upload += c.upload
|
||||
entry.download += c.download
|
||||
}
|
||||
// 连接多的排前面,和表格的默认排序一个取向
|
||||
return [...byName.values()].sort((a, b) => b.count - a.count)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { diffHunks, diffLines } from './diff.ts'
|
||||
|
||||
const lines = (rows: ReturnType<typeof diffLines>) => rows.map((r) => `${r.kind}:${r.text}`)
|
||||
|
||||
describe('行级 diff', () => {
|
||||
it('相同的内容全是 same,折叠后没有片段', () => {
|
||||
const rows = diffLines('a\nb\nc', 'a\nb\nc')
|
||||
expect(rows.every((r) => r.kind === 'same')).toBe(true)
|
||||
expect(diffHunks(rows)).toEqual([])
|
||||
})
|
||||
|
||||
it('中间插入一行', () => {
|
||||
expect(lines(diffLines('a\nc', 'a\nb\nc'))).toEqual(['same:a', 'add:b', 'same:c'])
|
||||
})
|
||||
|
||||
it('中间删除一行', () => {
|
||||
expect(lines(diffLines('a\nb\nc', 'a\nc'))).toEqual(['same:a', 'del:b', 'same:c'])
|
||||
})
|
||||
|
||||
it('改一行等于删旧行加新行', () => {
|
||||
expect(lines(diffLines('a\nx\nc', 'a\ny\nc'))).toEqual(['same:a', 'del:x', 'add:y', 'same:c'])
|
||||
})
|
||||
|
||||
it('从空到有、从有到空', () => {
|
||||
expect(lines(diffLines('', 'a'))).toEqual(['del:', 'add:a'])
|
||||
expect(lines(diffLines('a\nb', ''))).toEqual(['del:a', 'del:b', 'add:'])
|
||||
})
|
||||
|
||||
it('公共前后缀之外的多处改动都找得到', () => {
|
||||
const before = ['p1', 'p2', 'x', 'm', 'y', 's1'].join('\n')
|
||||
const after = ['p1', 'p2', 'x2', 'm', 'y2', 's1'].join('\n')
|
||||
expect(lines(diffLines(before, after))).toEqual([
|
||||
'same:p1',
|
||||
'same:p2',
|
||||
'del:x',
|
||||
'add:x2',
|
||||
'same:m',
|
||||
'del:y',
|
||||
'add:y2',
|
||||
'same:s1',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('折叠成片段', () => {
|
||||
it('改动行带前后上下文,远处的未改动行不进片段', () => {
|
||||
const rows = diffLines(
|
||||
['1', '2', '3', '4', '5', '6', '7', '8', '9'].join('\n'),
|
||||
['1', '2', '3', 'x', '5', '6', '7', '8', '9'].join('\n'),
|
||||
)
|
||||
const hunks = diffHunks(rows, 1)
|
||||
expect(hunks).toHaveLength(1)
|
||||
expect(hunks[0]!.map((r) => r.text)).toEqual(['3', '4', 'x', '5'])
|
||||
})
|
||||
|
||||
it('相距很远的两处改动分成两个片段,挨得近就合并成一个', () => {
|
||||
const before = ['a', '1', '2', '3', '4', '5', '6', '7', 'b'].join('\n')
|
||||
const afterFar = ['a2', '1', '2', '3', '4', '5', '6', '7', 'b2'].join('\n')
|
||||
expect(diffHunks(diffLines(before, afterFar), 1)).toHaveLength(2)
|
||||
const afterNear = ['a2', '1', '2', '3', '4', '5', '6', '7b', 'b'].join('\n')
|
||||
expect(diffHunks(diffLines(before, afterNear), 4)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 行级 diff,给历史记录的「这次改动改了什么」用。
|
||||
*
|
||||
* 先掐掉公共前后缀再对中段做 LCS:配置文件的一次改动通常只碰几行,中段一般
|
||||
* 很小,LCS 的 O(n×m) 表因此撑得住。真遇到两份大面积不同的文件(中段乘积超过
|
||||
* 上限),退化成「整段删除 + 整段新增」——依旧正确,只是不够精细。
|
||||
*/
|
||||
|
||||
export interface DiffLine {
|
||||
kind: 'same' | 'add' | 'del'
|
||||
text: string
|
||||
}
|
||||
|
||||
const LCS_LIMIT = 1_000_000
|
||||
|
||||
export function diffLines(before: string, after: string): DiffLine[] {
|
||||
const a = before.split('\n')
|
||||
const b = after.split('\n')
|
||||
|
||||
let start = 0
|
||||
while (start < a.length && start < b.length && a[start] === b[start]) start += 1
|
||||
let endA = a.length
|
||||
let endB = b.length
|
||||
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
||||
endA -= 1
|
||||
endB -= 1
|
||||
}
|
||||
|
||||
return [
|
||||
...a.slice(0, start).map((text): DiffLine => ({ kind: 'same', text })),
|
||||
...diffMiddle(a.slice(start, endA), b.slice(start, endB)),
|
||||
...a.slice(endA).map((text): DiffLine => ({ kind: 'same', text })),
|
||||
]
|
||||
}
|
||||
|
||||
function diffMiddle(a: string[], b: string[]): DiffLine[] {
|
||||
const allDel = a.map((text): DiffLine => ({ kind: 'del', text }))
|
||||
const allAdd = b.map((text): DiffLine => ({ kind: 'add', text }))
|
||||
if (a.length === 0 || b.length === 0 || a.length * b.length > LCS_LIMIT) {
|
||||
return [...allDel, ...allAdd]
|
||||
}
|
||||
|
||||
// LCS 长度表倒着填,回溯时从头正着走
|
||||
const w = b.length + 1
|
||||
const table = new Uint32Array((a.length + 1) * w)
|
||||
for (let i = a.length - 1; i >= 0; i -= 1) {
|
||||
for (let j = b.length - 1; j >= 0; j -= 1) {
|
||||
table[i * w + j] =
|
||||
a[i] === b[j]
|
||||
? table[(i + 1) * w + j + 1]! + 1
|
||||
: Math.max(table[(i + 1) * w + j]!, table[i * w + j + 1]!)
|
||||
}
|
||||
}
|
||||
|
||||
const rows: DiffLine[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < a.length && j < b.length) {
|
||||
if (a[i] === b[j]) {
|
||||
rows.push({ kind: 'same', text: a[i]! })
|
||||
i += 1
|
||||
j += 1
|
||||
} else if (table[(i + 1) * w + j]! >= table[i * w + j + 1]!) {
|
||||
rows.push({ kind: 'del', text: a[i]! })
|
||||
i += 1
|
||||
} else {
|
||||
rows.push({ kind: 'add', text: b[j]! })
|
||||
j += 1
|
||||
}
|
||||
}
|
||||
while (i < a.length) rows.push({ kind: 'del', text: a[i++]! })
|
||||
while (j < b.length) rows.push({ kind: 'add', text: b[j++]! })
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* 折叠成只含改动的片段:每个片段是改动行加前后 context 行上下文,片段之间的
|
||||
* 未改动区域丢弃(渲染端画省略号)。两份内容相同时返回空数组。
|
||||
*/
|
||||
export function diffHunks(rows: DiffLine[], context = 2): DiffLine[][] {
|
||||
const keep = new Array<boolean>(rows.length).fill(false)
|
||||
rows.forEach((row, index) => {
|
||||
if (row.kind === 'same') return
|
||||
const from = Math.max(0, index - context)
|
||||
const to = Math.min(rows.length - 1, index + context)
|
||||
for (let k = from; k <= to; k += 1) keep[k] = true
|
||||
})
|
||||
|
||||
const hunks: DiffLine[][] = []
|
||||
let current: DiffLine[] | null = null
|
||||
rows.forEach((row, index) => {
|
||||
if (!keep[index]) {
|
||||
current = null
|
||||
return
|
||||
}
|
||||
if (!current) {
|
||||
current = []
|
||||
hunks.push(current)
|
||||
}
|
||||
current.push(row)
|
||||
})
|
||||
return hunks
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { flagEmoji, formatAgo, formatBytes } from './format.ts'
|
||||
|
||||
describe('流量数字', () => {
|
||||
it('零和不满 1KB 的按字节显示,不带小数', () => {
|
||||
expect(formatBytes(0)).toBe('0 B')
|
||||
expect(formatBytes(512)).toBe('512 B')
|
||||
expect(formatBytes(1023)).toBe('1023 B')
|
||||
})
|
||||
|
||||
it('1024 的边界正好换档', () => {
|
||||
expect(formatBytes(1024)).toBe('1 KB')
|
||||
expect(formatBytes(1024 * 1024)).toBe('1 MB')
|
||||
})
|
||||
|
||||
it('不满 100 保留一位小数,够 100 取整', () => {
|
||||
expect(formatBytes(23.4 * 1024 * 1024)).toBe('23.4 MB')
|
||||
expect(formatBytes(234.7 * 1024 * 1024)).toBe('235 MB')
|
||||
})
|
||||
|
||||
it('超大数停在 TB 档', () => {
|
||||
expect(formatBytes(5 * 1024 ** 4)).toBe('5 TB')
|
||||
expect(formatBytes(5000 * 1024 ** 4)).toBe('5000 TB')
|
||||
})
|
||||
|
||||
it('脏输入不炸', () => {
|
||||
expect(formatBytes(-1)).toBe('0 B')
|
||||
expect(formatBytes(Number.NaN)).toBe('0 B')
|
||||
})
|
||||
})
|
||||
|
||||
describe('旗帜 emoji', () => {
|
||||
it('两位国家代码转成区域指示符对,大小写都认', () => {
|
||||
expect(flagEmoji('JP')).toBe('🇯🇵')
|
||||
expect(flagEmoji('us')).toBe('🇺🇸')
|
||||
})
|
||||
|
||||
it('不像国家代码的输入原样返回', () => {
|
||||
expect(flagEmoji('')).toBe('')
|
||||
expect(flagEmoji('USA')).toBe('USA')
|
||||
expect(flagEmoji('1X')).toBe('1X')
|
||||
})
|
||||
})
|
||||
|
||||
describe('相对时间', () => {
|
||||
const now = new Date('2026-08-12T12:00:00Z')
|
||||
const ago = (ms: number) => new Date(now.getTime() - ms)
|
||||
|
||||
it('一分钟内是刚刚,之后按分、时、天换档', () => {
|
||||
expect(formatAgo(ago(30_000), now)).toBe('刚刚')
|
||||
expect(formatAgo(ago(21 * 60_000), now)).toBe('21 分钟前')
|
||||
expect(formatAgo(ago(3 * 3600_000), now)).toBe('3 小时前')
|
||||
expect(formatAgo(ago(5 * 86400_000), now)).toBe('5 天前')
|
||||
})
|
||||
|
||||
it('超过 30 天给日期', () => {
|
||||
expect(formatAgo(new Date('2026-05-01T00:00:00'), now)).toBe('2026-05-01')
|
||||
})
|
||||
|
||||
it('未来的时间当成刚刚,不出负数', () => {
|
||||
expect(formatAgo(ago(-60_000), now)).toBe('刚刚')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 流量数字显示成人读的样子。连接页和仪表盘共用。
|
||||
*
|
||||
* 1024 进制。B 一档不带小数;其余不满 100 的保留一位小数,够 100 的取整——
|
||||
* 「23.4 MB」有信息量,「234.7 MB」的小数就只是噪音了。
|
||||
*/
|
||||
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < UNITS.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
const digits = unit === 0 || value >= 100 ? 0 : 1
|
||||
// Number() 去掉无意义的尾零:1.0 KB → 1 KB
|
||||
return `${String(Number(value.toFixed(digits)))} ${UNITS[unit]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个过去时刻距今多久,「21 分钟前」的形态。now 由调用方传,纯函数才测得了。
|
||||
* 超过 30 天相对时间就没有信息量了,直接给日期。
|
||||
*/
|
||||
export function formatAgo(at: Date, now: Date): string {
|
||||
const seconds = Math.max(0, (now.getTime() - at.getTime()) / 1000)
|
||||
if (seconds < 60) return '刚刚'
|
||||
if (seconds < 3600) return `${String(Math.floor(seconds / 60))} 分钟前`
|
||||
if (seconds < 86400) return `${String(Math.floor(seconds / 3600))} 小时前`
|
||||
if (seconds < 30 * 86400) return `${String(Math.floor(seconds / 86400))} 天前`
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${String(at.getFullYear())}-${p(at.getMonth() + 1)}-${p(at.getDate())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 两位国家代码转成旗帜 emoji(区域指示符对)。不像国家代码的输入原样返回,
|
||||
* 显示端至少还有字母可看。注意 Windows 的系统字体没有旗帜字形,浏览器会
|
||||
* 把区域指示符退回显示成两个字母。
|
||||
*/
|
||||
export function flagEmoji(countryCode: string): string {
|
||||
const code = countryCode.trim().toUpperCase()
|
||||
if (!/^[A-Z]{2}$/.test(code)) return countryCode
|
||||
return String.fromCodePoint(...[...code].map((ch) => 0x1f1e6 + (ch.codePointAt(0) ?? 65) - 65))
|
||||
}
|
||||
|
||||
/** 运行时长。一小时内说分钟,一天内说时分,再长说天和小时;秒级抖动没有信息量。 */
|
||||
export function formatUptime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '—'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${String(minutes)} 分钟`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${String(hours)} 小时 ${String(minutes % 60)} 分`
|
||||
return `${String(Math.floor(hours / 24))} 天 ${String(hours % 24)} 小时`
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { looksLikeGeodata, looksLikeMmdb } from './geo.ts'
|
||||
|
||||
describe('GEO 文件的形状检查', () => {
|
||||
test('末尾带 MaxMind 标记的通过,纯垃圾不通过', () => {
|
||||
const marker = new Uint8Array([
|
||||
0xab,
|
||||
0xcd,
|
||||
0xef,
|
||||
...Array.from('MaxMind.com', (c) => c.charCodeAt(0)),
|
||||
])
|
||||
const good = new Uint8Array(4096)
|
||||
good.set(marker, 4000)
|
||||
expect(looksLikeMmdb(good)).toBe(true)
|
||||
expect(looksLikeMmdb(new Uint8Array(4096))).toBe(false)
|
||||
})
|
||||
|
||||
test('标记只认末尾 128KB,太靠前算无效', () => {
|
||||
const marker = new Uint8Array([
|
||||
0xab,
|
||||
0xcd,
|
||||
0xef,
|
||||
...Array.from('MaxMind.com', (c) => c.charCodeAt(0)),
|
||||
])
|
||||
const bytes = new Uint8Array(300 * 1024)
|
||||
bytes.set(marker, 100)
|
||||
expect(looksLikeMmdb(bytes)).toBe(false)
|
||||
})
|
||||
|
||||
test('geodata 认首字节 0x0a,HTML 错误页和小文件被挡下', () => {
|
||||
const good = new Uint8Array(2048)
|
||||
good[0] = 0x0a
|
||||
expect(looksLikeGeodata(good)).toBe(true)
|
||||
|
||||
const html = new Uint8Array(2048)
|
||||
html[0] = '<'.charCodeAt(0)
|
||||
expect(looksLikeGeodata(html)).toBe(false)
|
||||
|
||||
const tiny = new Uint8Array(10)
|
||||
tiny[0] = 0x0a
|
||||
expect(looksLikeGeodata(tiny)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* GEO 数据库的四类文件。名字和默认下载地址都来自内核源码,
|
||||
* 界面(资源一节)和服务端(查文件大小和时间)共用这一份。
|
||||
*/
|
||||
|
||||
export interface GeoFileStatus {
|
||||
/** 界面上显示的名字 */
|
||||
key: string
|
||||
/** 在配置 geox-url 段落里的键名(config.go:363-368) */
|
||||
configKey: string
|
||||
/** 下载地址:配置里写了用配置的,没写是内核默认 */
|
||||
url: string
|
||||
/** 文件字节数。文件还没下载过是 null */
|
||||
size: number | null
|
||||
/** 文件修改时间,ISO 格式。没下载过是 null */
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件名与内核一致(constant/path.go:18-20、144)。
|
||||
* GeoIP.dat 和 GeoSite.dat 内核在目录里大小写不敏感地找(path.go:193-224),
|
||||
* 服务端查文件时也要同样处理。
|
||||
*/
|
||||
export const GEO_FILES = [
|
||||
{ key: 'MMDB', configKey: 'mmdb', file: 'geoip.metadb' },
|
||||
{ key: 'ASN', configKey: 'asn', file: 'ASN.mmdb' },
|
||||
{ key: 'GEOIP', configKey: 'geoip', file: 'GeoIP.dat' },
|
||||
{ key: 'GEOSITE', configKey: 'geosite', file: 'GeoSite.dat' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 下载内容的形状检查,挡住 HTML 错误页、截断的下载这类明显的坏文件。
|
||||
* Patchbay 自己下载单个 GEO 文件时用(内核的更新接口是全量的,单文件
|
||||
* 下载只能自己来,也就拿不到内核那套完整校验)。
|
||||
*/
|
||||
|
||||
/** MaxMind 格式:元数据标记按规范出现在文件末尾 128KB 内。MMDB 和 ASN 都是这个格式。 */
|
||||
export function looksLikeMmdb(bytes: Uint8Array): boolean {
|
||||
const marker = [0xab, 0xcd, 0xef, ...Array.from('MaxMind.com', (c) => c.charCodeAt(0))]
|
||||
const from = Math.max(0, bytes.length - 128 * 1024)
|
||||
outer: for (let i = from; i <= bytes.length - marker.length; i++) {
|
||||
for (let j = 0; j < marker.length; j++) {
|
||||
if (bytes[i + j] !== marker[j]) continue outer
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* geoip.dat / geosite.dat:GeoIPList 和 GeoSiteList 都是「repeated 消息字段 1」,
|
||||
* 首字节必然是 0x0a。弱校验,但足够挡住以 '<' 开头的错误页和空文件。
|
||||
*/
|
||||
export function looksLikeGeodata(bytes: Uint8Array): boolean {
|
||||
return bytes.length > 1024 && bytes[0] === 0x0a
|
||||
}
|
||||
|
||||
/** 内核的默认下载地址(config.go:578-583)。配置里没写 geox-url 时就是这些。 */
|
||||
export const DEFAULT_GEOX_URLS: Record<string, string> = {
|
||||
mmdb: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb',
|
||||
asn: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb',
|
||||
geoip: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat',
|
||||
geosite: 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat',
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
cpuUsageBetween,
|
||||
parseGateway,
|
||||
parseMemAvailable,
|
||||
parseMemTotal,
|
||||
parseNameservers,
|
||||
} from './host-info.ts'
|
||||
|
||||
describe('parseMemTotal', () => {
|
||||
test('取 MemTotal 并换算成字节', () => {
|
||||
const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n'
|
||||
expect(parseMemTotal(meminfo)).toBe(2048000 * 1024)
|
||||
})
|
||||
|
||||
test('读不到 /proc/meminfo(非 Linux 平台)返回 null', () => {
|
||||
expect(parseMemTotal('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMemAvailable', () => {
|
||||
test('取 MemAvailable 并换算成字节', () => {
|
||||
const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n'
|
||||
expect(parseMemAvailable(meminfo)).toBe(512000 * 1024)
|
||||
})
|
||||
|
||||
test('没有这一行(老内核)返回 null', () => {
|
||||
expect(parseMemAvailable('MemTotal: 1 kB\nMemFree: 1 kB\n')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseGateway', () => {
|
||||
test('默认路由的网关按小端序倒回来', () => {
|
||||
// 0102A8C0 → c0.a8.02.01 → 192.168.2.1
|
||||
const table = [
|
||||
'Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT',
|
||||
'eth0\t00000000\t0102A8C0\t0003\t0\t0\t0\t00000000\t0\t0\t0',
|
||||
'eth0\t0002A8C0\t00000000\t0001\t0\t0\t0\t00FFFFFF\t0\t0\t0',
|
||||
].join('\n')
|
||||
expect(parseGateway(table)).toBe('192.168.2.1')
|
||||
})
|
||||
|
||||
test('没有默认路由返回 null', () => {
|
||||
const table = 'Iface\tDestination\tGateway\neth0\t0002A8C0\t00000000'
|
||||
expect(parseGateway(table)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNameservers', () => {
|
||||
test('按出现顺序取 nameserver', () => {
|
||||
const conf = '# comment\nnameserver 223.5.5.5\nsearch lan\nnameserver 114.114.114.114\n'
|
||||
expect(parseNameservers(conf)).toEqual(['223.5.5.5', '114.114.114.114'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('cpuUsageBetween', () => {
|
||||
test('按两次快照的差值算百分比', () => {
|
||||
const usage = cpuUsageBetween({ busy: 100, total: 1000 }, { busy: 350, total: 2000 })
|
||||
expect(usage).toBe(25)
|
||||
})
|
||||
|
||||
test('时间没有前进时返回 null', () => {
|
||||
expect(cpuUsageBetween({ busy: 1, total: 10 }, { busy: 1, total: 10 })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 主机信息的纯解析。
|
||||
*
|
||||
* mihomo 的管理接口只报自己的内存,CPU、主机内存、网关这些要靠 Patchbay 服务端
|
||||
* 自己读 /proc 和 os 模块——服务端和内核跑在同一台机器上,这正是它能代答的原因。
|
||||
* 读文件在 server/host.ts;这里只做字符串到数字的解析,让 bun test 够得着。
|
||||
*/
|
||||
|
||||
/** GET /api/host 的响应。数据由 server/host.ts 采集,类型放这里让前后端共用。 */
|
||||
export interface HostInfo {
|
||||
/** 0-100。第一次请求没有参照快照,是 null */
|
||||
cpuUsage: number | null
|
||||
cpuCount: number
|
||||
/** 内存总量。Linux 用 MemTotal,其余平台退回 totalmem */
|
||||
memTotal: number
|
||||
/** 还能用的内存。Linux 用 MemAvailable,其余平台退回 freemem */
|
||||
memAvailable: number
|
||||
/** 1 分钟负载。Windows 上拿不到,是 null */
|
||||
load1: number | null
|
||||
uptimeSeconds: number
|
||||
platform: string
|
||||
release: string
|
||||
arch: string
|
||||
/** 非回环网卡上的 IPv4,含网卡名 */
|
||||
lanAddresses: Array<{ iface: string; address: string }>
|
||||
gateway: string | null
|
||||
nameservers: string[]
|
||||
}
|
||||
|
||||
/** GET /api/host/exit 的响应:经由某个入站端口探测到的公网 IP。 */
|
||||
export interface ExitProbe {
|
||||
ok: boolean
|
||||
ip?: string
|
||||
country?: string
|
||||
countryCode?: string
|
||||
/** 整个探测来回的毫秒数 */
|
||||
ms?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
function meminfoBytes(meminfo: string, field: string): number | null {
|
||||
const match = new RegExp(`^${field}:\\s+(\\d+)\\s*kB`, 'm').exec(meminfo)
|
||||
if (!match) return null
|
||||
return Number(match[1]) * 1024
|
||||
}
|
||||
|
||||
/**
|
||||
* /proc/meminfo 里的 MemTotal,换算成字节。
|
||||
*
|
||||
* 不能用 os.totalmem():它底层是 sysinfo() 系统调用,LXC 里的 lxcfs 只虚拟化
|
||||
* /proc 下的文件、拦不住系统调用,所以 sysinfo() 返回的是宿主机的物理内存总量。
|
||||
* 从 /proc/meminfo 读才是容器视角,且与 MemAvailable 出自同一份数据。
|
||||
*/
|
||||
export function parseMemTotal(meminfo: string): number | null {
|
||||
return meminfoBytes(meminfo, 'MemTotal')
|
||||
}
|
||||
|
||||
/** /proc/meminfo 里的 MemAvailable(内核估算的「还能用多少」),换算成字节。 */
|
||||
export function parseMemAvailable(meminfo: string): number | null {
|
||||
return meminfoBytes(meminfo, 'MemAvailable')
|
||||
}
|
||||
|
||||
/**
|
||||
* /proc/net/route 里的默认网关。
|
||||
*
|
||||
* 格式是制表分隔的表:目的网络为 00000000 的那行是默认路由,Gateway 列是
|
||||
* 小端序的十六进制 IPv4——字节序要倒回来才是人读的顺序。
|
||||
*/
|
||||
export function parseGateway(procNetRoute: string): string | null {
|
||||
for (const line of procNetRoute.split('\n').slice(1)) {
|
||||
const cols = line.trim().split(/\s+/)
|
||||
if (cols.length < 3 || cols[1] !== '00000000') continue
|
||||
const hex = cols[2]!
|
||||
if (!/^[0-9a-fA-F]{8}$/.test(hex)) continue
|
||||
const bytes = [6, 4, 2, 0].map((i) => parseInt(hex.slice(i, i + 2), 16))
|
||||
return bytes.join('.')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** /etc/resolv.conf 里的 DNS 服务器,按出现顺序。 */
|
||||
export function parseNameservers(resolvConf: string): string[] {
|
||||
const result: string[] = []
|
||||
for (const line of resolvConf.split('\n')) {
|
||||
const match = /^\s*nameserver\s+(\S+)/.exec(line)
|
||||
if (match) result.push(match[1]!)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 一次 CPU 时间快照:忙碌和总量的累计毫秒(os.cpus() 各核求和)。 */
|
||||
export interface CpuTimes {
|
||||
busy: number
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 两次快照之间的 CPU 使用率,0 到 100。
|
||||
* 时间没有前进(同一毫秒内连问两次,或计数回绕)时返回 null,不硬造一个数。
|
||||
*/
|
||||
export function cpuUsageBetween(prev: CpuTimes, next: CpuTimes): number | null {
|
||||
const total = next.total - prev.total
|
||||
const busy = next.busy - prev.busy
|
||||
if (total <= 0 || busy < 0) return null
|
||||
return Math.min(100, (busy / total) * 100)
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
applySetup,
|
||||
applySubRules,
|
||||
chainGroupName,
|
||||
chainProviderName,
|
||||
managedWiring,
|
||||
panelGroups,
|
||||
readSubRules,
|
||||
sharedPeers,
|
||||
subRuleName,
|
||||
syncGenerated,
|
||||
isGenerated,
|
||||
linesMatching,
|
||||
proxyGroupName,
|
||||
pruneGenerated,
|
||||
readSetup,
|
||||
upgradeLegacyInbound,
|
||||
} from './inbound-groups.ts'
|
||||
import type { OutboundSetup } from './inbound-groups.ts'
|
||||
import { collectInbounds } from './inbounds.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
const URL = 'http://127.0.0.1:3001/token/download/V2House'
|
||||
|
||||
/** 一份最小但完整的配置:一个订阅、一路入站,还没被 Patchbay 接管过。 */
|
||||
function base(): ConfigObject {
|
||||
return {
|
||||
'proxy-providers': {
|
||||
subs: { type: 'http', url: URL, path: './providers/subs.yaml', interval: 86400 },
|
||||
},
|
||||
listeners: [{ name: 'test1', type: 'mixed', port: 7778 }],
|
||||
}
|
||||
}
|
||||
|
||||
const plain: OutboundSetup = { proxy: { sources: ['subs'], filter: null }, chain: null }
|
||||
const chained: OutboundSetup = {
|
||||
proxy: { sources: ['subs'], filter: null },
|
||||
chain: { sources: ['subs'], filter: '(?i)DMIT' },
|
||||
}
|
||||
|
||||
describe('名字', () => {
|
||||
it('三种生成物都带前缀', () => {
|
||||
// 前缀是识别它们的唯一可靠标记,注释会被配置改写引擎丢掉(决策记录 0002)
|
||||
expect(isGenerated(proxyGroupName('test1'))).toBe(true)
|
||||
expect(isGenerated(chainGroupName('test1'))).toBe(true)
|
||||
expect(isGenerated(chainProviderName('test1', 'subs'))).toBe(true)
|
||||
})
|
||||
|
||||
it('用户自己建的组不会被认成生成的', () => {
|
||||
expect(isGenerated('PROXY')).toBe(false)
|
||||
})
|
||||
|
||||
it('不同的「入站 × 来源」组合不撞名', () => {
|
||||
// proxy-providers 是个映射,撞名会让其中一份被静默丢掉,
|
||||
// 表现是链式代理挂到了别的入站上
|
||||
const names = new Set([
|
||||
chainProviderName('a', 'b c'),
|
||||
chainProviderName('a b', 'c'),
|
||||
chainProviderName('a', 'b'),
|
||||
])
|
||||
expect(names.size).toBe(3)
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
||||
describe('筛选预览', () => {
|
||||
const lines = ['[vmess]DMIT.LAX.Pro.MTYQ', '[vless]Gomami.SIN Secret', '香港01', '香港011']
|
||||
|
||||
it('没写筛选条件就是全要', () => {
|
||||
expect(linesMatching(null, lines)).toEqual(lines)
|
||||
})
|
||||
|
||||
it('部分匹配,不自动加锚点', () => {
|
||||
// 内核的筛选就是部分匹配,界面预览必须跟它一致,否则预览的数字骗人
|
||||
expect(linesMatching('香港01', lines)).toEqual(['香港01', '香港011'])
|
||||
})
|
||||
|
||||
it('反引号切成多条正则,取并集', () => {
|
||||
expect(linesMatching('DMIT`Gomami', lines)).toEqual([
|
||||
'[vmess]DMIT.LAX.Pro.MTYQ',
|
||||
'[vless]Gomami.SIN Secret',
|
||||
])
|
||||
})
|
||||
|
||||
it('写法有误时返回空,让界面提示而不是显示一个错的数字', () => {
|
||||
expect(linesMatching('[未闭合', lines)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('一路入站的三个组', () => {
|
||||
it('SELECT 的成员是另外两个组加 DIRECT,再加上订阅,AUTO 排第一', () => {
|
||||
// AUTO 必须排第一:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅删掉时
|
||||
// 也回退到第一个成员。这两种情况落到 DIRECT 上就是静默直连(selector.go:103-112)
|
||||
const groups = applySetup(base(), 'test1', plain)['proxy-groups'] as ConfigObject[]
|
||||
expect(groups[0]).toEqual({
|
||||
name: 'patchbay:test1',
|
||||
type: 'select',
|
||||
proxies: ['patchbay:test1 AUTO', 'patchbay:test1 FALLBACK', 'DIRECT'],
|
||||
use: ['subs'],
|
||||
})
|
||||
})
|
||||
|
||||
it('AUTO 是 url-test,FALLBACK 是 fallback,都带测速地址', () => {
|
||||
// 不写测速地址时内核会去借来源订阅的健康检查配置,那依赖订阅写了它,不能指望
|
||||
const groups = applySetup(base(), 'test1', plain)['proxy-groups'] as ConfigObject[]
|
||||
expect(groups[1]).toEqual({
|
||||
name: 'patchbay:test1 AUTO',
|
||||
type: 'url-test',
|
||||
use: ['subs'],
|
||||
url: 'https://www.gstatic.com/generate_204',
|
||||
interval: 300,
|
||||
})
|
||||
expect((groups[2] as ConfigObject).type).toBe('fallback')
|
||||
})
|
||||
|
||||
it('一个来源都没选时只有 SELECT,成员只有 DIRECT', () => {
|
||||
// AUTO 和 FALLBACK 只有 use: 没有点名成员,来源为空就是空组,内核会拒绝
|
||||
// 整份配置(parser.go:117-119)。新增入站落在这个状态上,等用户自己挑订阅
|
||||
const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null }
|
||||
const groups = applySetup(base(), 'test1', empty)['proxy-groups'] as ConfigObject[]
|
||||
expect(groups).toEqual([{ name: 'patchbay:test1', type: 'select', proxies: ['DIRECT'] }])
|
||||
})
|
||||
|
||||
it('从空来源选上订阅后,AUTO 和 FALLBACK 建回来', () => {
|
||||
const empty: OutboundSetup = { proxy: { sources: [], filter: null }, chain: null }
|
||||
const data = applySetup(applySetup(base(), 'test1', empty), 'test1', plain)
|
||||
const groups = data['proxy-groups'] as ConfigObject[]
|
||||
expect(groups.map((g) => g.name)).toEqual([
|
||||
'patchbay:test1',
|
||||
'patchbay:test1 AUTO',
|
||||
'patchbay:test1 FALLBACK',
|
||||
])
|
||||
// 反过来把订阅全取消,那两个组要跟着消失,否则留下的是内核不收的空组
|
||||
const back = applySetup(data, 'test1', empty)['proxy-groups'] as ConfigObject[]
|
||||
expect(back.map((g) => g.name)).toEqual(['patchbay:test1'])
|
||||
})
|
||||
|
||||
it('tab 的顺序是 SELECT、AUTO、FALLBACK,只有 SELECT 能点', () => {
|
||||
// 另外两个自己决定用哪条,手动指定会被它们自己撤销,见 outbound-chain.ts
|
||||
const data = applySetup(base(), 'test1', plain)
|
||||
const runtime = {
|
||||
'patchbay:test1': { type: 'Selector' },
|
||||
'patchbay:test1 AUTO': { type: 'URLTest' },
|
||||
'patchbay:test1 FALLBACK': { type: 'Fallback' },
|
||||
}
|
||||
const inbound = collectInbounds(data)[0]!
|
||||
const panel = panelGroups(data, runtime, inbound)
|
||||
|
||||
expect(panel.shared).toBe(false)
|
||||
expect(panel.groups.map((g) => g.label)).toEqual(['SELECT', 'AUTO', 'FALLBACK'])
|
||||
expect(panel.groups.map((g) => g.pickable)).toEqual([true, false, false])
|
||||
expect(panel.groups[0]!.uses).toEqual(['subs'])
|
||||
})
|
||||
|
||||
it('内核里还没有的组不出现在 tab 上', () => {
|
||||
// 保存完到内核重载完之间会有这么一小段,画一个点不出东西的 tab 只会让人以为坏了
|
||||
const data = applySetup(base(), 'test1', plain)
|
||||
const inbound = collectInbounds(data)[0]!
|
||||
expect(panelGroups(data, {}, inbound).groups).toEqual([])
|
||||
})
|
||||
|
||||
it('没接管的入站落到它顺着规则表走到的那个手动选择组,并标为共用', () => {
|
||||
const data: ConfigObject = {
|
||||
...base(),
|
||||
rules: ['MATCH,PROXY'],
|
||||
'proxy-groups': [{ name: 'PROXY', type: 'select', use: ['subs'] }],
|
||||
}
|
||||
const runtime = { PROXY: { type: 'Selector', now: 'A', all: ['A'] } }
|
||||
const panel = panelGroups(data, runtime, collectInbounds(data)[0]!)
|
||||
|
||||
expect(panel.shared).toBe(true)
|
||||
expect(panel.groups).toEqual([
|
||||
{ name: 'PROXY', label: 'PROXY', uses: ['subs'], pickable: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('两路没接管的入站落到同一个组时互为共用者', () => {
|
||||
// 「共用」标记只该出现在这种情况:在其中一路上换选中会连带改掉另一路的去向
|
||||
const data: ConfigObject = {
|
||||
...base(),
|
||||
'mixed-port': 7890,
|
||||
rules: ['MATCH,PROXY'],
|
||||
'proxy-groups': [{ name: 'PROXY', type: 'select', use: ['subs'] }],
|
||||
}
|
||||
const runtime = { PROXY: { type: 'Selector', now: 'A', all: ['A'] } }
|
||||
const inbounds = collectInbounds(data)
|
||||
const legacy = inbounds.find((i) => i.isDefault)!
|
||||
|
||||
expect(sharedPeers(data, runtime, inbounds, legacy)).toEqual(['test1'])
|
||||
})
|
||||
|
||||
it('只有自己落在那个组上时没有共用者', () => {
|
||||
const data: ConfigObject = {
|
||||
'mixed-port': 7890,
|
||||
rules: ['MATCH,PROXY'],
|
||||
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'] }],
|
||||
}
|
||||
const runtime = { PROXY: { type: 'Selector', now: 'DIRECT', all: ['DIRECT'] } }
|
||||
const inbounds = collectInbounds(data)
|
||||
|
||||
expect(sharedPeers(data, runtime, inbounds, inbounds[0]!)).toEqual([])
|
||||
})
|
||||
|
||||
it('接管过的入站用的是自己的组,谈不上共用', () => {
|
||||
const data = applySetup(base(), 'test1', plain)
|
||||
const runtime = { 'patchbay:test1': { type: 'Selector' } }
|
||||
const inbounds = collectInbounds(data)
|
||||
|
||||
expect(sharedPeers(data, runtime, inbounds, inbounds[0]!)).toEqual([])
|
||||
})
|
||||
|
||||
it('筛选条件三个组共用一份', () => {
|
||||
const groups = applySetup(base(), 'test1', {
|
||||
proxy: { sources: ['subs'], filter: '(?i)hk' },
|
||||
chain: null,
|
||||
})['proxy-groups'] as ConfigObject[]
|
||||
for (const g of groups) expect((g as ConfigObject).filter).toBe('(?i)hk')
|
||||
})
|
||||
})
|
||||
|
||||
describe('重建成当前形状', () => {
|
||||
it('旧形状的生成组会被补齐成三个', () => {
|
||||
// 改过生成规则之后,早先建的那些停在旧形状上,界面按新形状去找就静默少一块
|
||||
const stale: ConfigObject = {
|
||||
...base(),
|
||||
'proxy-groups': [{ name: 'patchbay:test1', type: 'select', use: ['subs'] }],
|
||||
listeners: [{ name: 'test1', type: 'mixed', port: 7778, proxy: 'patchbay:test1' }],
|
||||
}
|
||||
const fixed = syncGenerated(stale, collectInbounds(stale))
|
||||
const names = (fixed['proxy-groups'] as ConfigObject[]).map((g) => (g as ConfigObject).name)
|
||||
|
||||
expect(names).toEqual(['patchbay:test1', 'patchbay:test1 AUTO', 'patchbay:test1 FALLBACK'])
|
||||
})
|
||||
|
||||
it('没接管的入站不碰', () => {
|
||||
const data = base()
|
||||
expect(syncGenerated(data, collectInbounds(data))).toBe(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('读取设置', () => {
|
||||
it('没有代理组时返回空,表示这一路由别的方式决定去向', () => {
|
||||
expect(readSetup(base(), 'test1')).toBeNull()
|
||||
})
|
||||
|
||||
it('没开链式代理时,来源就是代理组的 use', () => {
|
||||
const setup: OutboundSetup = { proxy: { sources: ['subs'], filter: '(?i)hk' }, chain: null }
|
||||
expect(readSetup(applySetup(base(), 'test1', setup), 'test1')).toEqual(setup)
|
||||
})
|
||||
|
||||
it('开了链式代理时,来源从副本的 path 还原', () => {
|
||||
// 代理组的 use 这时指向副本,用户选的订阅只能靠读同一个缓存文件认回来
|
||||
const data = applySetup(base(), 'test1', chained)
|
||||
expect(readSetup(data, 'test1')).toEqual(chained)
|
||||
})
|
||||
})
|
||||
|
||||
describe('写回设置', () => {
|
||||
it('开链式代理时建入口组,三个组一起改用副本', () => {
|
||||
const groups = applySetup(base(), 'test1', chained)['proxy-groups'] as ConfigObject[]
|
||||
const copyName = chainProviderName('test1', 'subs')
|
||||
|
||||
// 只有副本挂着 dialer-proxy,自动测速和故障转移也得走副本,否则那两条路绕过了中转
|
||||
for (const g of groups.slice(0, 3)) expect((g as ConfigObject).use).toEqual([copyName])
|
||||
expect(groups[3]).toEqual({
|
||||
name: 'patchbay:test1 链式',
|
||||
type: 'select',
|
||||
use: ['subs'],
|
||||
filter: '(?i)DMIT',
|
||||
})
|
||||
})
|
||||
|
||||
it('副本读原订阅的缓存文件,自己不联网', () => {
|
||||
// 副本要是自己去拉,订阅地址一不可达它就是空的,那一路入站的线路会全部消失,
|
||||
// 表现是端口还在监听却一条线都没有
|
||||
const data = applySetup(base(), 'test1', chained)
|
||||
const copy = (data['proxy-providers'] as ConfigObject)[
|
||||
chainProviderName('test1', 'subs')
|
||||
] as ConfigObject
|
||||
|
||||
expect(copy.type).toBe('file')
|
||||
expect(copy.path).toBe('./providers/subs.yaml')
|
||||
expect(copy.url).toBeUndefined()
|
||||
expect(copy.interval).toBeUndefined()
|
||||
expect(copy.override).toEqual({ 'dialer-proxy': 'patchbay:test1 链式' })
|
||||
})
|
||||
|
||||
it('原订阅没写 path 时给它补一个,否则副本认不到该读哪个文件', () => {
|
||||
// 内核会按 url 哈希自己生成缓存路径,那个路径 Patchbay 算不出来
|
||||
const data = base()
|
||||
delete ((data['proxy-providers'] as ConfigObject).subs as ConfigObject).path
|
||||
const next = applySetup(data, 'test1', chained)
|
||||
const providers = next['proxy-providers'] as ConfigObject
|
||||
|
||||
expect((providers.subs as ConfigObject).path).toBe('./providers/subs.yaml')
|
||||
expect(readSetup(next, 'test1')).toEqual(chained)
|
||||
})
|
||||
|
||||
it('源订阅原有的 override 保留,不被覆盖', () => {
|
||||
const data = base()
|
||||
;((data['proxy-providers'] as ConfigObject).subs as ConfigObject).override = { udp: true }
|
||||
const next = applySetup(data, 'test1', chained)
|
||||
const copy = (next['proxy-providers'] as ConfigObject)[
|
||||
chainProviderName('test1', 'subs')
|
||||
] as ConfigObject
|
||||
|
||||
expect(copy.override).toEqual({ udp: true, 'dialer-proxy': 'patchbay:test1 链式' })
|
||||
})
|
||||
|
||||
it('关掉链式代理时,入口组和副本一起消失', () => {
|
||||
const off = applySetup(applySetup(base(), 'test1', chained), 'test1', plain)
|
||||
const names = (off['proxy-groups'] as ConfigObject[]).map((g) => (g as ConfigObject).name)
|
||||
|
||||
expect(names).not.toContain('patchbay:test1 链式')
|
||||
expect(Object.keys(off['proxy-providers'] as ConfigObject)).toEqual(['subs'])
|
||||
})
|
||||
|
||||
it('换来源时旧副本立刻消失', () => {
|
||||
// 留着的话就是一份没人引用、却还在按 interval 定时联网的订阅
|
||||
const data = base()
|
||||
const providers = data['proxy-providers'] as ConfigObject
|
||||
providers.other = { type: 'http', url: 'http://other.example/x', path: './providers/o.yaml' }
|
||||
|
||||
const first = applySetup(data, 'test1', chained)
|
||||
const second = applySetup(first, 'test1', {
|
||||
proxy: { sources: ['other'], filter: null },
|
||||
chain: { sources: ['subs'], filter: null },
|
||||
})
|
||||
|
||||
const names = Object.keys(second['proxy-providers'] as ConfigObject)
|
||||
expect(names).toContain(chainProviderName('test1', 'other'))
|
||||
expect(names).not.toContain(chainProviderName('test1', 'subs'))
|
||||
})
|
||||
|
||||
it('两路入站各自的副本互不干扰', () => {
|
||||
const data = base()
|
||||
;(data.listeners as ConfigObject[]).push({ name: 'test2', type: 'mixed', port: 7779 })
|
||||
|
||||
const next = applySetup(applySetup(data, 'test1', chained), 'test2', chained)
|
||||
const providers = next['proxy-providers'] as ConfigObject
|
||||
|
||||
expect(Object.keys(providers)).toHaveLength(3)
|
||||
expect(readSetup(next, 'test1')).toEqual(chained)
|
||||
expect(readSetup(next, 'test2')).toEqual(chained)
|
||||
})
|
||||
})
|
||||
|
||||
describe('清理', () => {
|
||||
it('删掉入站之后,几个组和副本连锁消失', () => {
|
||||
// 链式引用:SELECT 引 AUTO 和 FALLBACK,三者引副本,副本引入口组,要反复扫才清得干净
|
||||
const withInbound = applySetup(base(), 'test1', chained)
|
||||
const removed: ConfigObject = { ...withInbound, listeners: [] }
|
||||
|
||||
const cleaned = pruneGenerated(removed)
|
||||
expect(cleaned['proxy-groups']).toBeUndefined()
|
||||
expect(Object.keys(cleaned['proxy-providers'] as ConfigObject)).toEqual(['subs'])
|
||||
})
|
||||
|
||||
it('入站还在就一个都不删', () => {
|
||||
const data = applySetup(base(), 'test1', chained)
|
||||
expect(pruneGenerated(data)).toBe(data)
|
||||
})
|
||||
|
||||
it('用户自己建的组和订阅一律不碰,哪怕真的没人引用', () => {
|
||||
const data: ConfigObject = {
|
||||
'proxy-groups': [{ name: '我建的组', type: 'select', use: ['subs'] }],
|
||||
'proxy-providers': { subs: { type: 'http', url: URL } },
|
||||
}
|
||||
expect(pruneGenerated(data)).toBe(data)
|
||||
})
|
||||
|
||||
it('没有可删的时候原样返回,不制造无谓的改动', () => {
|
||||
// 返回同一个对象,落盘链路才看得出「内容没变」而跳过备份和重载
|
||||
const data: ConfigObject = { 'proxy-groups': [{ name: 'PROXY' }] }
|
||||
expect(pruneGenerated(data)).toBe(data)
|
||||
})
|
||||
|
||||
it('被规则引用的生成组不删,连它引用的两个组一起留下', () => {
|
||||
const data = applySetup(base(), 'test1', plain)
|
||||
const orphaned: ConfigObject = { ...data, listeners: [], rules: ['MATCH,patchbay:test1'] }
|
||||
expect((pruneGenerated(orphaned)['proxy-groups'] as ConfigObject[]).length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('附加规则', () => {
|
||||
const managed = () => applySetup(base(), 'test1', plain)
|
||||
const listenerOf = (data: ConfigObject) => (data.listeners as ConfigObject[])[0]!
|
||||
|
||||
it('写入后 listener 换成 rule 形态,表底自动补兜底 MATCH', () => {
|
||||
const next = applySubRules(managed(), 'test1', ['DOMAIN-SUFFIX,lan,DIRECT'])
|
||||
|
||||
const listener = listenerOf(next)
|
||||
expect(listener.rule).toBe(subRuleName('test1'))
|
||||
expect(listener.proxy).toBeUndefined()
|
||||
expect((next['sub-rules'] as ConfigObject)[subRuleName('test1')]).toEqual([
|
||||
'DOMAIN-SUFFIX,lan,DIRECT',
|
||||
'MATCH,patchbay:test1',
|
||||
])
|
||||
})
|
||||
|
||||
it('读出来不含兜底 MATCH,往返一致', () => {
|
||||
const rules = ['DOMAIN-SUFFIX,lan,DIRECT', 'GEOIP,CN,DIRECT']
|
||||
const next = applySubRules(managed(), 'test1', rules)
|
||||
expect(readSubRules(next, 'test1')).toEqual(rules)
|
||||
expect(readSubRules(managed(), 'test1')).toEqual([])
|
||||
})
|
||||
|
||||
it('清空后恢复 proxy 直连形态,sub-rules 键整个消失', () => {
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const cleared = applySubRules(withRules, 'test1', [])
|
||||
|
||||
const listener = listenerOf(cleared)
|
||||
expect(listener.proxy).toBe(proxyGroupName('test1'))
|
||||
expect(listener.rule).toBeUndefined()
|
||||
expect(cleared['sub-rules']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rule 形态仍被认成 Patchbay 接线,重建设置不会把 proxy 加回来', () => {
|
||||
// proxy 会短路规则匹配(tunnel.go:318-325),加回来附加规则就静默失效了
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const inbound = collectInbounds(withRules).find((i) => i.name === 'test1')!
|
||||
expect(managedWiring(withRules, inbound)).toBe(true)
|
||||
|
||||
const rebuilt = syncGenerated(withRules, collectInbounds(withRules))
|
||||
const listener = listenerOf(rebuilt)
|
||||
expect(listener.rule).toBe(subRuleName('test1'))
|
||||
expect(listener.proxy).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rule 形态下专属组靠表底 MATCH 活着,不会被当孤儿清掉', () => {
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const pruned = pruneGenerated(withRules)
|
||||
const groupNames = (pruned['proxy-groups'] as ConfigObject[]).map((g) => g.name)
|
||||
expect(groupNames).toContain(proxyGroupName('test1'))
|
||||
})
|
||||
|
||||
it('删掉入站后,附加规则表和组连锁消失', () => {
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const removed: ConfigObject = { ...withRules, listeners: [] }
|
||||
|
||||
const cleaned = pruneGenerated(removed)
|
||||
expect(cleaned['sub-rules']).toBeUndefined()
|
||||
expect(cleaned['proxy-groups']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('用户自己的 sub-rules 表不被清理', () => {
|
||||
const data: ConfigObject = { ...base(), 'sub-rules': { mine: ['MATCH,DIRECT'] } }
|
||||
expect((pruneGenerated(data)['sub-rules'] as ConfigObject).mine).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy 入站升级', () => {
|
||||
/** 形状仿照目标机器:一个 legacy 混合端口,全局规则是内网直连加 MATCH 兜底。 */
|
||||
function legacyBase(): ConfigObject {
|
||||
return {
|
||||
'mixed-port': 7890,
|
||||
'allow-lan': true,
|
||||
'proxy-providers': {
|
||||
subs: { type: 'http', url: URL, path: './providers/subs.yaml' },
|
||||
other: { type: 'http', url: `${URL}2`, path: './providers/other.yaml' },
|
||||
},
|
||||
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'], use: ['subs'] }],
|
||||
rules: [
|
||||
'IP-CIDR,192.168.0.0/16,DIRECT,no-resolve',
|
||||
'MATCH,PROXY',
|
||||
'DOMAIN,dead.example,DIRECT',
|
||||
],
|
||||
}
|
||||
}
|
||||
const legacyInbound = (data: ConfigObject) => collectInbounds(data).find((i) => i.isDefault)!
|
||||
|
||||
it('删掉顶层键,建同端口的命名入站,listen 按 allow-lan 推导写死', () => {
|
||||
const data = legacyBase()
|
||||
const next = upgradeLegacyInbound(data, legacyInbound(data))!
|
||||
expect(next['mixed-port']).toBeUndefined()
|
||||
expect((next.listeners as ConfigObject[]).at(-1)).toMatchObject({
|
||||
name: 'mixed-port',
|
||||
type: 'mixed',
|
||||
port: 7890,
|
||||
listen: '0.0.0.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('allow-lan 关着时 listen 写 127.0.0.1', () => {
|
||||
const data = { ...legacyBase(), 'allow-lan': false }
|
||||
const next = upgradeLegacyInbound(data, legacyInbound(data))!
|
||||
expect(((next.listeners as ConfigObject[]).at(-1) as ConfigObject).listen).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it('全局规则里第一条 MATCH 之前的部分复制成附加规则,全局规则原文不动', () => {
|
||||
const data = legacyBase()
|
||||
const next = upgradeLegacyInbound(data, legacyInbound(data))!
|
||||
expect(readSubRules(next, 'mixed-port')).toEqual(['IP-CIDR,192.168.0.0/16,DIRECT,no-resolve'])
|
||||
expect(next.rules).toEqual(legacyBase().rules)
|
||||
})
|
||||
|
||||
it('升级完就在专属组模式下,来源照抄 MATCH 目标组的 use', () => {
|
||||
const data = legacyBase()
|
||||
const next = upgradeLegacyInbound(data, legacyInbound(data))!
|
||||
expect(readSetup(next, 'mixed-port')).toEqual({
|
||||
proxy: { sources: ['subs'], filter: null },
|
||||
chain: null,
|
||||
})
|
||||
expect(managedWiring(next, collectInbounds(next).find((i) => i.name === 'mixed-port')!)).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('MATCH 目标组没有 use 时退回全部订阅', () => {
|
||||
const data = legacyBase()
|
||||
;(data['proxy-groups'] as ConfigObject[])[0] = { name: 'PROXY', type: 'select', proxies: ['DIRECT'] }
|
||||
const next = upgradeLegacyInbound(data, legacyInbound(data))!
|
||||
expect(readSetup(next, 'mixed-port')?.proxy.sources).toEqual(['subs', 'other'])
|
||||
})
|
||||
|
||||
it('已有同名入站时返回 null,不写出重名的 listener', () => {
|
||||
const data = { ...legacyBase(), listeners: [{ name: 'mixed-port', type: 'mixed', port: 1 }] }
|
||||
expect(upgradeLegacyInbound(data, legacyInbound(data))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,708 @@
|
||||
/**
|
||||
* 每一路入站的专属策略组。
|
||||
*
|
||||
* 一路入站配三个组,入站的 `proxy:` 指向其中的 SELECT,换线是换那个组的当前选中,
|
||||
* 不改配置文件(决策记录 0007):
|
||||
* SELECT 手动选。成员是 AUTO、FALLBACK、DIRECT 加订阅里的线路,顺序见 applySetup
|
||||
* AUTO url-test,自动挑延迟最低的
|
||||
* FALLBACK fallback,按顺序挑第一个测得通的
|
||||
* 界面上这三个是并排的 tab。做成三个而不是让用户自己配,是因为它们就是所有 clash 配置
|
||||
* 里那三种固定的挑法,让人从零拼一遍没有意义。
|
||||
*
|
||||
* 开了链式代理时再多一个入口组,以及若干份订阅声明的副本——`dialer-proxy` 写不到策略组
|
||||
* 上,只能挂在整个订阅上(内核源码 adapter/outboundgroup/parser.go:68-70)。
|
||||
*
|
||||
* 配置里的东西属于谁全靠三条线索还原,不靠解析名字:
|
||||
* 四个组 名字由入站名推出来
|
||||
* 订阅副本 override.dialer-proxy 指向哪一路的入口组
|
||||
* 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件)
|
||||
*/
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { fallbackTarget, legacyListenAddress, resolveDestination } from './inbounds.ts'
|
||||
import type { Inbound } from './inbounds.ts'
|
||||
import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts'
|
||||
import type { RuntimeNode } from './outbound-chain.ts'
|
||||
|
||||
/**
|
||||
* Patchbay 自动创建的策略组和订阅的名字前缀。
|
||||
*
|
||||
* 名字是识别它们的唯一可靠标记。注释靠不住——配置改写引擎重建整个被碰到的顶层段落,
|
||||
* 段落内的注释会丢(决策记录 0002)。改这个前缀会让已有配置里的组失去标记,别改。
|
||||
*/
|
||||
export const GENERATED_PREFIX = 'patchbay:'
|
||||
|
||||
export function isGenerated(name: string): boolean {
|
||||
return name.startsWith(GENERATED_PREFIX)
|
||||
}
|
||||
|
||||
/** 手动选的那个组。入站的 `proxy:` 指向它,它的当前选中决定这一路走哪。 */
|
||||
export function proxyGroupName(inbound: string): string {
|
||||
return `${GENERATED_PREFIX}${inbound}`
|
||||
}
|
||||
|
||||
/** 自动测速组,作为 SELECT 的一个成员出现。 */
|
||||
export function autoGroupName(inbound: string): string {
|
||||
return `${GENERATED_PREFIX}${inbound} AUTO`
|
||||
}
|
||||
|
||||
/** 故障转移组,同上。 */
|
||||
export function fallbackGroupName(inbound: string): string {
|
||||
return `${GENERATED_PREFIX}${inbound} FALLBACK`
|
||||
}
|
||||
|
||||
/** 链式代理的入口组。订阅副本的 `dialer-proxy` 指向它。 */
|
||||
export function chainGroupName(inbound: string): string {
|
||||
return `${GENERATED_PREFIX}${inbound} 链式`
|
||||
}
|
||||
|
||||
/** 这一路入站的附加规则表在顶层 `sub-rules:` 里的键名。listener 的 `rule:` 指向它。 */
|
||||
export function subRuleName(inbound: string): string {
|
||||
return `${GENERATED_PREFIX}${inbound} 附加`
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个出站在界面上叫什么。
|
||||
*
|
||||
* 生成的组要去掉 `patchbay:` 前缀和入站名。用户从没建过这些组,让他在卡片和链条上读到
|
||||
* `patchbay:test1 FALLBACK` 只是噪音——那一屏本来就是这一路入站的详情,名字里再重复
|
||||
* 一遍入站名不增加任何信息。
|
||||
*/
|
||||
export function displayName(name: string): string {
|
||||
if (!isGenerated(name)) return name
|
||||
const rest = name.slice(GENERATED_PREFIX.length)
|
||||
for (const [suffix, label] of [
|
||||
[' AUTO', 'AUTO'],
|
||||
[' FALLBACK', 'FALLBACK'],
|
||||
[' 链式', '链式代理'],
|
||||
[' 附加', '附加规则'],
|
||||
] as const) {
|
||||
if (rest.endsWith(suffix)) return label
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
/** 这一路的三个组,按界面上的显示顺序。 */
|
||||
function ownGroups(inbound: string): Array<{ name: string; label: string }> {
|
||||
return [
|
||||
{ name: proxyGroupName(inbound), label: 'SELECT' },
|
||||
{ name: autoGroupName(inbound), label: 'AUTO' },
|
||||
{ name: fallbackGroupName(inbound), label: 'FALLBACK' },
|
||||
]
|
||||
}
|
||||
|
||||
/** FNV-1a。只用来给名字和文件名去重,不是安全用途,所以不引哈希库,浏览器端也要能算。 */
|
||||
function shortHash(text: string): string {
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
h ^= text.charCodeAt(i)
|
||||
h = Math.imul(h, 0x01000193) >>> 0
|
||||
}
|
||||
return h.toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
const pairKey = (inbound: string, source: string) => `${inbound} ${source}`
|
||||
|
||||
/**
|
||||
* 「某一路入站 × 某个来源订阅」这个组合对应的订阅副本叫什么。
|
||||
*
|
||||
* 名字里带哈希是因为 `proxy-providers` 是个映射,两个组合撞名会让其中一个被静默丢掉,
|
||||
* 表现是链式代理挂到了别的入站上。带上入站名只是为了翻配置文件时读得懂。
|
||||
*/
|
||||
export function chainProviderName(inbound: string, source: string): string {
|
||||
return `${GENERATED_PREFIX}chain ${inbound} ${shortHash(pairKey(inbound, source))}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个订阅的缓存文件落在哪。
|
||||
*
|
||||
* 内核把拉回来的内容写在这里(内核源码 component/resource/vehicle.go:42-47)。没写
|
||||
* `path:` 时它按 url 哈希自己生成一个(adapter/provider/parser.go:87),那个路径 Patchbay
|
||||
* 算不出来,所以缺的时候补一个确定的进去。
|
||||
*/
|
||||
export function providerCachePath(name: string): string {
|
||||
const safe = name.replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-+|-+$/g, '')
|
||||
return `./providers/${safe || shortHash(name)}.yaml`
|
||||
}
|
||||
|
||||
/** 一组线路从哪来、要哪些。三个组共用同一份,它们只是挑法不同。 */
|
||||
export interface GroupSpec {
|
||||
/** 成员来自哪几个订阅,写进组的 `use:` */
|
||||
sources: string[]
|
||||
/** 只要名字匹配这个正则的线路。null 表示全要 */
|
||||
filter: string | null
|
||||
}
|
||||
|
||||
/** 一路入站的出站设置。 */
|
||||
export interface OutboundSetup {
|
||||
proxy: GroupSpec
|
||||
/** 链式代理的入口。null 表示没开 */
|
||||
chain: GroupSpec | null
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is ConfigObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function groupsOf(data: ConfigObject): ConfigObject[] {
|
||||
return (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []).filter(isPlainObject)
|
||||
}
|
||||
|
||||
function providersOf(data: ConfigObject): ConfigObject {
|
||||
return isPlainObject(data['proxy-providers']) ? data['proxy-providers'] : {}
|
||||
}
|
||||
|
||||
function findGroup(data: ConfigObject, name: string): ConfigObject | null {
|
||||
return groupsOf(data).find((g) => g.name === name) ?? null
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
}
|
||||
|
||||
function specOf(group: ConfigObject): GroupSpec {
|
||||
return {
|
||||
sources: stringList(group.use),
|
||||
filter: typeof group.filter === 'string' && group.filter ? group.filter : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户自己配的订阅,不含 Patchbay 生成的副本。界面上「来源」能选的就是这些。 */
|
||||
export function userProviders(data: ConfigObject): string[] {
|
||||
return Object.keys(providersOf(data)).filter((name) => !isGenerated(name))
|
||||
}
|
||||
|
||||
/** 某个策略组从哪几个订阅取成员。组不存在时返回 null。 */
|
||||
export function groupUses(data: ConfigObject, name: string): string[] | null {
|
||||
const group = findGroup(data, name)
|
||||
return group ? stringList(group.use) : null
|
||||
}
|
||||
|
||||
/** 这个订阅副本是哪一路入站的。不是副本、或者认不出归属时返回 null。 */
|
||||
function chainOwnerOf(provider: unknown): string | null {
|
||||
if (!isPlainObject(provider)) return null
|
||||
const override = provider.override
|
||||
if (!isPlainObject(override)) return null
|
||||
const target = override['dialer-proxy']
|
||||
if (typeof target !== 'string' || !isGenerated(target)) return null
|
||||
const suffix = ' 链式'
|
||||
if (!target.endsWith(suffix)) return null
|
||||
return target.slice(GENERATED_PREFIX.length, target.length - suffix.length)
|
||||
}
|
||||
|
||||
/** 副本的来源订阅叫什么。靠读同一个缓存文件来认,因为名字不可靠解析。 */
|
||||
function sourceOfCopy(data: ConfigObject, copy: ConfigObject): string | null {
|
||||
if (typeof copy.path !== 'string') return null
|
||||
for (const [name, entry] of Object.entries(providersOf(data))) {
|
||||
if (isGenerated(name) || !isPlainObject(entry)) continue
|
||||
if (entry.path === copy.path) return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 读出这一路入站当前的出站设置。
|
||||
*
|
||||
* 返回 null 表示它还没在专属组模式下,可能是走规则表、直连,或者指向用户自己建的组。
|
||||
*/
|
||||
export function readSetup(data: ConfigObject, inbound: string): OutboundSetup | null {
|
||||
const proxyGroup = findGroup(data, proxyGroupName(inbound))
|
||||
if (!proxyGroup) return null
|
||||
|
||||
const spec = specOf(proxyGroup)
|
||||
const providers = providersOf(data)
|
||||
const copies = spec.sources.filter((name) => chainOwnerOf(providers[name]) === inbound)
|
||||
|
||||
if (copies.length === 0) return { proxy: spec, chain: null }
|
||||
|
||||
const chainGroup = findGroup(data, chainGroupName(inbound))
|
||||
const sources = copies
|
||||
.map((name) => sourceOfCopy(data, providers[name] as ConfigObject))
|
||||
.filter((name): name is string => name !== null)
|
||||
|
||||
return {
|
||||
proxy: { sources, filter: spec.filter },
|
||||
chain: chainGroup ? specOf(chainGroup) : { sources: [], filter: null },
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 附加规则 ─────────────────────────────────────
|
||||
*
|
||||
* 挂在某一路入站上的分流规则(内核的 sub-rules 机制):listener 写 `rule: <表名>`,
|
||||
* 该入站的流量改查顶层 `sub-rules:` 里同名的表(tunnel.go:713-721)。两个硬约束:
|
||||
* - `proxy:` 的优先级更高且完全短路规则匹配(tunnel.go:318-325),所以有附加规则时
|
||||
* listener 只能写 rule 不写 proxy;
|
||||
* - 子规则表没有命中时兜底是 DIRECT 而不是这一路的出站(tunnel.go match 循环末尾),
|
||||
* 所以表底那条 `MATCH,专属组` 是承重的,Patchbay 始终自动补上,不交给用户维护。
|
||||
* 于是不变式是:附加规则为空 ⇔ listener 用 proxy 直连专属组;非空 ⇔ listener 用 rule,
|
||||
* 表底 MATCH 指回专属组。两种形态对「没被规则分走的流量」效果完全相同。
|
||||
*/
|
||||
|
||||
function subRulesOf(data: ConfigObject): ConfigObject {
|
||||
return isPlainObject(data['sub-rules']) ? data['sub-rules'] : {}
|
||||
}
|
||||
|
||||
/** 这一路入站有没有 Patchbay 管理的附加规则表。 */
|
||||
export function hasSubRules(data: ConfigObject, inbound: string): boolean {
|
||||
return subRuleName(inbound) in subRulesOf(data)
|
||||
}
|
||||
|
||||
/** 表底的兜底规则。它把没被分走的流量送回这一路自己的出站。 */
|
||||
function terminalRule(inbound: string): string {
|
||||
return `MATCH,${proxyGroupName(inbound)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 读出这一路的附加规则,不含表底的兜底 MATCH——那条是生成物,界面上单独画、
|
||||
* 不参与编辑。手改过、兜底不在末尾的表原样全给,编辑保存时会重新规整。
|
||||
*/
|
||||
export function readSubRules(data: ConfigObject, inbound: string): string[] {
|
||||
const entry = subRulesOf(data)[subRuleName(inbound)]
|
||||
const rules = Array.isArray(entry)
|
||||
? entry.filter((rule): rule is string => typeof rule === 'string')
|
||||
: []
|
||||
return rules.at(-1) === terminalRule(inbound) ? rules.slice(0, -1) : rules
|
||||
}
|
||||
|
||||
/**
|
||||
* 把这一路的附加规则写回配置。
|
||||
*
|
||||
* 空表恢复 proxy 直连形态(删表、删 rule、接回专属组);非空补上兜底 MATCH、
|
||||
* listener 换成 rule。listener 上用户手写的其它字段原样保留。
|
||||
*/
|
||||
export function applySubRules(data: ConfigObject, inbound: string, rules: string[]): ConfigObject {
|
||||
const next = { ...data }
|
||||
const name = subRuleName(inbound)
|
||||
const tables = { ...subRulesOf(next) }
|
||||
|
||||
if (rules.length === 0) delete tables[name]
|
||||
else tables[name] = [...rules, terminalRule(inbound)]
|
||||
|
||||
if (Object.keys(tables).length === 0) delete next['sub-rules']
|
||||
else next['sub-rules'] = tables
|
||||
|
||||
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) => {
|
||||
if (!isPlainObject(entry) || entry.name !== inbound) return entry
|
||||
if (rules.length === 0) {
|
||||
const { rule: _rule, ...rest } = entry
|
||||
return { ...rest, proxy: proxyGroupName(inbound) }
|
||||
}
|
||||
const { proxy: _proxy, ...rest } = entry
|
||||
return { ...rest, rule: name }
|
||||
})
|
||||
if (listeners.length > 0) next.listeners = listeners
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一路入站是否由 Patchbay 接线。两种形态:proxy 直指专属组,
|
||||
* 或经附加规则表兜底到专属组。panelGroups 和 syncGenerated 共用这一个答案。
|
||||
*/
|
||||
export function managedWiring(data: ConfigObject, inbound: Inbound): boolean {
|
||||
const mode = inbound.outbound
|
||||
if (mode.kind === 'named' && mode.name === proxyGroupName(inbound.name)) return true
|
||||
return inbound.ruleSet === subRuleName(inbound.name) && hasSubRules(data, inbound.name)
|
||||
}
|
||||
|
||||
function makeGroup(
|
||||
name: string,
|
||||
type: string,
|
||||
use: string[],
|
||||
filter: string | null,
|
||||
members: string[] = [],
|
||||
): ConfigObject {
|
||||
const group: ConfigObject = { name, type }
|
||||
if (members.length > 0) group.proxies = members
|
||||
if (use.length > 0) group.use = use
|
||||
if (filter) group.filter = filter
|
||||
// url-test 和 fallback 要有测速地址才知道谁快谁通。不写的话内核会借用来源订阅自己的
|
||||
// 健康检查地址(adapter/outboundgroup/parser.go:139-148),但那依赖订阅写了它,
|
||||
// 不能指望;写死一个和内核默认一致的更稳。
|
||||
if (type !== 'select') {
|
||||
group.url = 'https://www.gstatic.com/generate_204'
|
||||
group.interval = 300
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
function upsert(list: ConfigObject[], entry: ConfigObject): ConfigObject[] {
|
||||
const at = list.findIndex((item) => item.name === entry.name)
|
||||
if (at < 0) return [...list, entry]
|
||||
const next = [...list]
|
||||
next[at] = entry
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 照着一个用户订阅做出带 `dialer-proxy` 的那一份。
|
||||
*
|
||||
* 副本是 `type: file`,读的正是原订阅那个缓存文件,自己不联网。这一点是硬性的:副本要是
|
||||
* 自己去拉,订阅地址一不可达它就是空的,而这一路入站的线路全部来自它,结果是端口还在
|
||||
* 监听却一条线都没有。派生自原订阅已经落盘的内容,原订阅有什么副本就有什么。
|
||||
*
|
||||
* 内核对 file 类型的 provider 自带文件监视(component/resource/fetcher.go:212-228),
|
||||
* 原订阅刷新写盘之后副本自动跟着更新,不需要 Patchbay 做任何事。
|
||||
*
|
||||
* `filter` 这些影响「这个订阅里有哪些线路」的字段照抄,`url` 和 `interval` 去掉,
|
||||
* 它们只对 http 类型有意义。
|
||||
*/
|
||||
function makeChainProvider(source: ConfigObject, inbound: string, cachePath: string): ConfigObject {
|
||||
const override = isPlainObject(source.override) ? { ...source.override } : {}
|
||||
override['dialer-proxy'] = chainGroupName(inbound)
|
||||
const { url: _url, interval: _interval, ...rest } = source
|
||||
return { ...rest, type: 'file', path: cachePath, override }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一路入站的出站设置写回配置。
|
||||
*
|
||||
* 开了链式代理时,三个组的成员都改从订阅副本里取,因为只有副本挂着 `dialer-proxy`。
|
||||
* 用户选的来源订阅仍然可以从副本的 `path` 还原出来,所以配置文件之外不需要另存状态。
|
||||
*/
|
||||
export function applySetup(data: ConfigObject, inbound: string, setup: OutboundSetup): ConfigObject {
|
||||
const next = { ...data }
|
||||
const chained = setup.chain !== null
|
||||
const providers = { ...providersOf(next) }
|
||||
|
||||
// 先清掉这一路原有的副本,再按新的来源重建。改来源时旧的副本必须消失,
|
||||
// 否则代理组不再引用它,它就成了一份没人用却还在定时拉取的订阅。
|
||||
for (const [name, entry] of Object.entries(providers)) {
|
||||
if (chainOwnerOf(entry) === inbound) delete providers[name]
|
||||
}
|
||||
|
||||
const use: string[] = []
|
||||
if (chained) {
|
||||
for (const sourceName of setup.proxy.sources) {
|
||||
const source = providers[sourceName]
|
||||
if (!isPlainObject(source)) continue
|
||||
// 副本靠「读同一个缓存文件」认亲。原订阅没写 path 时内核按 url 哈希自己生成一个,
|
||||
// 那个路径这边算不出来,所以先给原订阅补一个确定的。
|
||||
const cachePath =
|
||||
typeof source.path === 'string' && source.path ? source.path : providerCachePath(sourceName)
|
||||
providers[sourceName] = { ...source, path: cachePath }
|
||||
const copyName = chainProviderName(inbound, sourceName)
|
||||
providers[copyName] = makeChainProvider(providers[sourceName], inbound, cachePath)
|
||||
use.push(copyName)
|
||||
}
|
||||
} else {
|
||||
use.push(...setup.proxy.sources)
|
||||
}
|
||||
|
||||
const { filter } = setup.proxy
|
||||
let groups = groupsOf(next)
|
||||
if (use.length === 0) {
|
||||
// 一个来源都没选。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,来源为空就是空组,
|
||||
// 内核会拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119),所以这时
|
||||
// 只建 SELECT、成员只有 DIRECT。新增入站落在这个状态上,等用户自己挑订阅。
|
||||
// filter 一并不写:没有来源可筛。
|
||||
groups = upsert(groups, makeGroup(proxyGroupName(inbound), 'select', [], null, ['DIRECT']))
|
||||
groups = groups.filter(
|
||||
(g) => g.name !== autoGroupName(inbound) && g.name !== fallbackGroupName(inbound),
|
||||
)
|
||||
} else {
|
||||
// AUTO 排第一位:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时
|
||||
// 也静默回退到第一个成员(内核源码 adapter/outboundgroup/selector.go:103-112)。这两种
|
||||
// 情况都该落到自动测速上;排 DIRECT 就是静默直连,对按端口定向出站的场景是泄露。
|
||||
groups = upsert(
|
||||
groups,
|
||||
makeGroup(proxyGroupName(inbound), 'select', use, filter, [
|
||||
autoGroupName(inbound),
|
||||
fallbackGroupName(inbound),
|
||||
'DIRECT',
|
||||
]),
|
||||
)
|
||||
groups = upsert(groups, makeGroup(autoGroupName(inbound), 'url-test', use, filter))
|
||||
groups = upsert(groups, makeGroup(fallbackGroupName(inbound), 'fallback', use, filter))
|
||||
}
|
||||
|
||||
if (chained) {
|
||||
groups = upsert(
|
||||
groups,
|
||||
makeGroup(chainGroupName(inbound), 'select', setup.chain!.sources, setup.chain!.filter),
|
||||
)
|
||||
} else {
|
||||
groups = groups.filter((g) => g.name !== chainGroupName(inbound))
|
||||
}
|
||||
next['proxy-groups'] = groups
|
||||
|
||||
if (Object.keys(providers).length === 0) delete next['proxy-providers']
|
||||
else next['proxy-providers'] = providers
|
||||
|
||||
// 有附加规则时 listener 走 rule 形态,proxy 会短路规则匹配、不能写;
|
||||
// 表底的兜底 MATCH 已经指向专属组,接线效果一样
|
||||
const viaSubRules = hasSubRules(next, inbound)
|
||||
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) => {
|
||||
if (!isPlainObject(entry) || entry.name !== inbound) return entry
|
||||
if (viaSubRules) {
|
||||
const { proxy: _proxy, ...rest } = entry
|
||||
return { ...rest, rule: subRuleName(inbound) }
|
||||
}
|
||||
return { ...entry, proxy: proxyGroupName(inbound) }
|
||||
})
|
||||
if (listeners.length > 0) next.listeners = listeners
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一路 legacy 入站升级成命名入站,并直接进专属组模式。
|
||||
*
|
||||
* legacy 入站(顶层端口字段)没有 `proxy` 可写,只能顺全局规则走,在界面上
|
||||
* 切不了订阅。升级 = 删掉顶层端口键,在 `listeners:` 里建一个行为等价的入站:
|
||||
* 端口 原样
|
||||
* listen 按 allow-lan 推导后写死。升级后改 allow-lan 不再影响它,这是升级的
|
||||
* 语义变化之一,界面文案要说
|
||||
* udp 不写。命名入站解析时默认就是开的(内核源码 listener/parse.go:52)
|
||||
*
|
||||
* 行为等价还差分流:它原来查全局规则,升级后整表改查自己的附加规则,所以把
|
||||
* 全局规则里第一条 MATCH 之前的部分复制过来(MATCH 之后的行本来就永远轮不到)。
|
||||
* 全局规则原文不动——那是用户手写的段落,何况可能还有别的 legacy 入站在用。
|
||||
* 专属组的来源照抄原 MATCH 目标组的 `use:`,流量的实际去向尽量不变;
|
||||
* 目标组没有 `use:` 时退回全部订阅。
|
||||
*
|
||||
* 同名入站已存在时返回 null,调用方据此报错而不是写出重名的 listener。
|
||||
*/
|
||||
export function upgradeLegacyInbound(data: ConfigObject, inbound: Inbound): ConfigObject | null {
|
||||
if (inbound.legacyKey === null) return null
|
||||
const port = Number(inbound.port)
|
||||
if (!Number.isFinite(port) || port <= 0) return null
|
||||
const listeners = Array.isArray(data.listeners) ? data.listeners : []
|
||||
if (listeners.some((entry) => isPlainObject(entry) && entry.name === inbound.name)) return null
|
||||
|
||||
const next = { ...data }
|
||||
delete next[inbound.legacyKey]
|
||||
next.listeners = [
|
||||
...listeners,
|
||||
{ name: inbound.name, type: inbound.type, port, listen: legacyListenAddress(data) },
|
||||
]
|
||||
|
||||
const rules: string[] = []
|
||||
for (const rule of Array.isArray(data.rules) ? data.rules : []) {
|
||||
if (typeof rule !== 'string') continue
|
||||
if (rule.split(',')[0]?.trim().toUpperCase() === 'MATCH') break
|
||||
rules.push(rule)
|
||||
}
|
||||
|
||||
const target = fallbackTarget(data)
|
||||
const providers = new Set(userProviders(data))
|
||||
const uses = (target ? (groupUses(data, target) ?? []) : []).filter((name) => providers.has(name))
|
||||
const sources = uses.length > 0 ? uses : userProviders(data)
|
||||
|
||||
return applySetup(applySubRules(next, inbound.name, rules), inbound.name, {
|
||||
proxy: { sources, filter: null },
|
||||
chain: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** 配置里指向某个名字的地方有几处。用来判断生成的东西还有没有人在用。 */
|
||||
function referencesTo(data: ConfigObject, name: string): number {
|
||||
let count = 0
|
||||
|
||||
for (const entry of Array.isArray(data.listeners) ? data.listeners : []) {
|
||||
if (isPlainObject(entry) && entry.proxy === name) count += 1
|
||||
}
|
||||
|
||||
for (const group of groupsOf(data)) {
|
||||
if (stringList(group.proxies).includes(name)) count += 1
|
||||
if (stringList(group.use).includes(name)) count += 1
|
||||
}
|
||||
|
||||
for (const entry of Object.values(providersOf(data))) {
|
||||
if (!isPlainObject(entry) || !isPlainObject(entry.override)) continue
|
||||
if (entry.override['dialer-proxy'] === name) count += 1
|
||||
}
|
||||
|
||||
for (const rule of Array.isArray(data.rules) ? data.rules : []) {
|
||||
// 规则是逗号分隔的,目标出站是其中一段,形如 MATCH,目标 或 DOMAIN,x,目标
|
||||
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) count += 1
|
||||
}
|
||||
|
||||
// 附加规则表里的规则同样引用出站——表底的 MATCH,专属组 正是 rule 形态下
|
||||
// 专属组活着的唯一理由,不数它专属组会被当孤儿清掉
|
||||
for (const table of Object.values(subRulesOf(data))) {
|
||||
if (!Array.isArray(table)) continue
|
||||
for (const rule of table) {
|
||||
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* 删掉没人再引用的生成组和订阅副本。
|
||||
*
|
||||
* 每次改完配置都跑一遍。删一路入站之后,它的几个组和几份副本会先后变成孤儿,不清理的话
|
||||
* 文件里就攒下一堆用户从没主动建过的东西,而副本还在按 interval 定时联网。
|
||||
*
|
||||
* 要反复扫:AUTO 和 FALLBACK 被 SELECT 引用,入口组被副本引用,副本被那三个组引用,
|
||||
* 所以删掉 SELECT 之后下一轮才轮到它们。只动带前缀的,用户自己建的一律不碰。
|
||||
*/
|
||||
export function pruneGenerated(data: ConfigObject): ConfigObject {
|
||||
const groups = Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []
|
||||
const keptGroups = groups.filter((group) => {
|
||||
if (!isPlainObject(group)) return true
|
||||
const name = group.name
|
||||
if (typeof name !== 'string' || !isGenerated(name)) return true
|
||||
return referencesTo(data, name) > 0
|
||||
})
|
||||
|
||||
const providers = providersOf(data)
|
||||
const keptProviders = Object.entries(providers).filter(
|
||||
([name]) => !isGenerated(name) || referencesTo(data, name) > 0,
|
||||
)
|
||||
const providersChanged = keptProviders.length !== Object.keys(providers).length
|
||||
|
||||
// 生成的附加规则表活着的唯一理由是有 listener 的 rule 指着它。
|
||||
// 入站被删后先清表,表里的 MATCH 引用消失,下一轮才轮到那几个组
|
||||
const listenerRules = new Set(
|
||||
(Array.isArray(data.listeners) ? data.listeners : [])
|
||||
.filter(isPlainObject)
|
||||
.map((entry) => entry.rule)
|
||||
.filter((rule): rule is string => typeof rule === 'string'),
|
||||
)
|
||||
const tables = subRulesOf(data)
|
||||
const keptTables = Object.entries(tables).filter(
|
||||
([name]) => !isGenerated(name) || listenerRules.has(name),
|
||||
)
|
||||
const tablesChanged = keptTables.length !== Object.keys(tables).length
|
||||
|
||||
if (keptGroups.length === groups.length && !providersChanged && !tablesChanged) return data
|
||||
|
||||
const next = { ...data }
|
||||
if (keptGroups.length === 0) delete next['proxy-groups']
|
||||
else next['proxy-groups'] = keptGroups
|
||||
|
||||
if (providersChanged) {
|
||||
if (keptProviders.length === 0) delete next['proxy-providers']
|
||||
else next['proxy-providers'] = Object.fromEntries(keptProviders)
|
||||
}
|
||||
|
||||
if (tablesChanged) {
|
||||
if (keptTables.length === 0) delete next['sub-rules']
|
||||
else next['sub-rules'] = Object.fromEntries(keptTables)
|
||||
}
|
||||
|
||||
return pruneGenerated(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所有已接管入站的生成组按当前的生成规则重建一遍。
|
||||
*
|
||||
* 每次写配置前跑。生成的东西必须永远和生成它的代码一致,否则改过生成规则之后,早先建的
|
||||
* 那些就停在旧形状上,界面按新形状去找,找不到就静默少一块——tab 少了两个正是这么来的。
|
||||
* `applySetup` 幂等,形状没变时返回的内容一样,落盘链路会看出内容没变而跳过重载。
|
||||
*/
|
||||
export function syncGenerated(data: ConfigObject, inbounds: Inbound[]): ConfigObject {
|
||||
let next = data
|
||||
for (const inbound of inbounds) {
|
||||
const setup = readSetup(next, inbound.name)
|
||||
if (!setup || !managedWiring(next, inbound)) continue
|
||||
next = applySetup(next, inbound.name, setup)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** 面板上一个 tab 对应的策略组。 */
|
||||
export interface PanelGroup {
|
||||
name: string
|
||||
/** tab 上显示什么。自己的组显示挑法,共用的组显示它自己的名字 */
|
||||
label: string
|
||||
/** 它从哪几个订阅取成员。测延迟要顺着来源找,见 web/runtime.ts 的 membersOf */
|
||||
uses: string[]
|
||||
/** 能不能点成员换选中 */
|
||||
pickable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一路入站的面板上能操作哪些策略组。
|
||||
*
|
||||
* 只此一处回答这个问题。之前它散在四个地方(页面算一次能不能点、组件再算一次、
|
||||
* 共用与否又各算一次),加一种情况就要同时改四处,漏一处就是界面和实际操作对不上。
|
||||
*
|
||||
* 两种情况:接管过的入站是它自己那三个组;没接管的(比如 `mixed-port`,配置顶层的端口
|
||||
* 字段没有 `proxy` 可写)落到它顺着规则表走到的那个手动选择组上,那个组是共用的。
|
||||
*/
|
||||
export function panelGroups(
|
||||
data: ConfigObject,
|
||||
runtime: Record<string, RuntimeNode>,
|
||||
inbound: Inbound,
|
||||
): { groups: PanelGroup[]; shared: boolean } {
|
||||
const managed = managedWiring(data, inbound)
|
||||
|
||||
if (managed) {
|
||||
return {
|
||||
shared: false,
|
||||
groups: ownGroups(inbound.name)
|
||||
// 保存完到内核重载完之间,组已经在配置里但运行时还没有,这时先不显示那个 tab
|
||||
.filter((g) => runtime[g.name])
|
||||
.map((g) => ({
|
||||
...g,
|
||||
uses: groupUses(data, g.name) ?? [],
|
||||
// 只有手动选的那个能点。另外两个自己决定用哪条,理由见 outbound-chain.ts
|
||||
pickable: runtime[g.name]?.type === 'Selector',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const chain = resolveChain(runtime, resolveDestination(data, inbound))
|
||||
const fallback = lastSwitchableGroup(runtime, chain)
|
||||
if (!fallback) return { groups: [], shared: false }
|
||||
return {
|
||||
shared: true,
|
||||
groups: [
|
||||
{
|
||||
name: fallback.group,
|
||||
label: fallback.group,
|
||||
uses: groupUses(data, fallback.group) ?? [],
|
||||
pickable: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 还有哪些入站和这一路落在同一个共用组上。
|
||||
*
|
||||
* 「共用」这个提醒只在真有人共用时才成立:几路没接管的入站顺着规则表落到同一个手动
|
||||
* 选择组时,在其中一路上换选中会连带改掉其它几路的去向。只有自己在用就没什么可提醒的。
|
||||
*/
|
||||
export function sharedPeers(
|
||||
data: ConfigObject,
|
||||
runtime: Record<string, RuntimeNode>,
|
||||
inbounds: Inbound[],
|
||||
inbound: Inbound,
|
||||
): string[] {
|
||||
const own = panelGroups(data, runtime, inbound)
|
||||
if (!own.shared) return []
|
||||
const group = own.groups[0]?.name
|
||||
return inbounds
|
||||
.filter((other) => other.id !== inbound.id)
|
||||
.filter((other) => {
|
||||
const panel = panelGroups(data, runtime, other)
|
||||
return panel.shared && panel.groups[0]?.name === group
|
||||
})
|
||||
.map((other) => other.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 筛选条件会留下哪些线路。界面上用来在保存前当场告诉用户「匹配到几条」。
|
||||
*
|
||||
* 照着内核的规则算(`adapter/outboundgroup/groupbase.go:72-76, 168-195`):反引号把
|
||||
* 条件切成多条独立正则,取并集;每条都是部分匹配,不自动加锚点。
|
||||
*
|
||||
* 内核用的是支持前后向查找的 .NET 风味引擎,浏览器只有 JS 的,所以复杂写法这里算出来的
|
||||
* 数字可能和内核不一致。写法有误时返回 null,界面据此提示而不是显示一个错的数字。
|
||||
*/
|
||||
export function linesMatching(filter: string | null, lines: string[]): string[] | null {
|
||||
if (!filter) return lines
|
||||
let regexps: RegExp[]
|
||||
try {
|
||||
regexps = filter.split('`').map((part) => new RegExp(part))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return lines.filter((line) => regexps.some((re) => re.test(line)))
|
||||
}
|
||||
+88
-8
@@ -2,7 +2,15 @@ import { describe, expect, it } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { openDocument } from '../vendor/config-editor/index.ts'
|
||||
import { collectInbounds, collectOutboundNames, outboundToProxyField } from './inbounds.ts'
|
||||
import {
|
||||
collectInbounds,
|
||||
collectOutboundNames,
|
||||
fallbackTarget,
|
||||
isUnsetOutbound,
|
||||
outboundLabel,
|
||||
OUTBOUND_PRESETS,
|
||||
resolveDestination,
|
||||
} from './inbounds.ts'
|
||||
|
||||
const fixture = openDocument(
|
||||
readFileSync(join(import.meta.dir, '__fixtures__', 'config-with-comments.yaml'), 'utf8'),
|
||||
@@ -34,11 +42,33 @@ describe('合并两套写法的入站', () => {
|
||||
expect(list[1]!.listenerIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
|
||||
it('legacy 入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
|
||||
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('老式端口实际绑在哪', () => {
|
||||
// 内核源码 listener/listener.go:709-718。界面上一律显示 0.0.0.0 会骗人。
|
||||
it('allow-lan 没开时只绑本机', () => {
|
||||
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.listen).toBe('127.0.0.1')
|
||||
expect(collectInbounds({ 'mixed-port': 7777, 'allow-lan': false })[0]!.listen).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it('allow-lan 开了但没指定地址时绑所有网卡', () => {
|
||||
expect(collectInbounds({ 'mixed-port': 7777, 'allow-lan': true })[0]!.listen).toBe('0.0.0.0')
|
||||
expect(
|
||||
collectInbounds({ 'mixed-port': 7777, 'allow-lan': true, 'bind-address': '*' })[0]!.listen,
|
||||
).toBe('0.0.0.0')
|
||||
})
|
||||
|
||||
it('指定了地址就用那个地址', () => {
|
||||
expect(
|
||||
collectInbounds({ 'mixed-port': 7777, 'allow-lan': true, 'bind-address': '192.168.1.5' })[0]!
|
||||
.listen,
|
||||
).toBe('192.168.1.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('出站去向的四种形态', () => {
|
||||
const of = (proxy?: string) => {
|
||||
// 用扩展而不是直接写 proxy: undefined —— 配置里「没有这个键」和「这个键是空」
|
||||
@@ -61,12 +91,6 @@ describe('出站去向的四种形态', () => {
|
||||
expect(of('AUTO')).toEqual({ kind: 'named', name: 'AUTO' })
|
||||
})
|
||||
|
||||
it('写回时能还原成原来的字段值', () => {
|
||||
expect(outboundToProxyField({ kind: 'rules' })).toBeUndefined()
|
||||
expect(outboundToProxyField({ kind: 'direct' })).toBe('DIRECT')
|
||||
expect(outboundToProxyField({ kind: 'global' })).toBe('GLOBAL')
|
||||
expect(outboundToProxyField({ kind: 'named', name: 'AUTO' })).toBe('AUTO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('能被入站引用的出站名字', () => {
|
||||
@@ -84,3 +108,59 @@ describe('能被入站引用的出站名字', () => {
|
||||
expect([...names.groups, ...names.ownProxies, ...names.builtins]).not.toContain('subs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('流量最终去哪', () => {
|
||||
const cfg = {
|
||||
'mixed-port': 7777,
|
||||
rules: ['IP-CIDR,127.0.0.0/8,DIRECT,no-resolve', 'MATCH,PROXY'],
|
||||
listeners: [
|
||||
{ name: '固定', type: 'mixed', port: 7778, proxy: 'AUTO' },
|
||||
{ name: '直连', type: 'mixed', port: 7779, proxy: 'DIRECT' },
|
||||
],
|
||||
}
|
||||
|
||||
it('走规则的入站要继续查到规则表末尾的 MATCH', () => {
|
||||
// 用户想知道的是流量去哪,不是 proxy 字段写没写
|
||||
const legacy = collectInbounds(cfg)[0]!
|
||||
expect(legacy.outbound).toEqual({ kind: 'rules' })
|
||||
expect(resolveDestination(cfg, legacy)).toBe('PROXY')
|
||||
})
|
||||
|
||||
it('指定了出站的入站就是那个出站', () => {
|
||||
const list = collectInbounds(cfg)
|
||||
expect(resolveDestination(cfg, list[1]!)).toBe('AUTO')
|
||||
expect(resolveDestination(cfg, list[2]!)).toBe('DIRECT')
|
||||
})
|
||||
|
||||
it('MATCH 之后的规则到不了,取第一条', () => {
|
||||
expect(fallbackTarget({ rules: ['MATCH,A', 'MATCH,B'] })).toBe('A')
|
||||
})
|
||||
|
||||
it('没有 MATCH 时没有兜底', () => {
|
||||
expect(fallbackTarget({ rules: ['MATCH-NOT-REALLY,X'] })).toBeNull()
|
||||
expect(fallbackTarget({})).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('出站预设表是读取和显示的唯一约定', () => {
|
||||
it('每种预设的字段值和标签一一对应', () => {
|
||||
expect(OUTBOUND_PRESETS.map((p) => [p.mode.kind, p.field, p.label])).toEqual([
|
||||
['rules', undefined, 'RULE'],
|
||||
['direct', 'DIRECT', 'DIRECT'],
|
||||
['global', 'GLOBAL', 'GLOBAL'],
|
||||
])
|
||||
})
|
||||
|
||||
it('界面上的名字用配置里的原词', () => {
|
||||
expect(outboundLabel({ kind: 'rules' })).toBe('RULE')
|
||||
expect(outboundLabel({ kind: 'direct' })).toBe('DIRECT')
|
||||
expect(outboundLabel({ kind: 'global' })).toBe('GLOBAL')
|
||||
expect(outboundLabel({ kind: 'named', name: '固定-Gomami' })).toBe('固定-Gomami')
|
||||
})
|
||||
|
||||
it('只有 RULE 算「没明确指定」,界面据此弱化显示', () => {
|
||||
expect(isUnsetOutbound({ kind: 'rules' })).toBe(true)
|
||||
expect(isUnsetOutbound({ kind: 'direct' })).toBe(false)
|
||||
expect(isUnsetOutbound({ kind: 'named', name: 'AUTO' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+103
-14
@@ -55,27 +55,73 @@ export interface Inbound {
|
||||
legacyKey: string | null
|
||||
/** 在 listeners 数组里的下标,用于写回。旧写法为 null */
|
||||
listenerIndex: number | null
|
||||
/** 给人看的一句话说明,主要是给默认入站解释它为什么不能改出站 */
|
||||
note: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 不用指名道姓的那几种出站,以及它们各自对应配置里的什么值。
|
||||
*
|
||||
* 这张表是「读配置 → 界面显示」的唯一约定来源。Patchbay 自己不会写出这些值
|
||||
* (出站模式的写方向已随设计废弃),但手写配置里它们都可能出现,读和显示必须认得。
|
||||
*
|
||||
* `field` 为 undefined 表示配置里**没写** proxy 这个字段——「没有这个键」和
|
||||
* 「这个键是空字符串」在 YAML 里是两回事。
|
||||
*/
|
||||
export const OUTBOUND_PRESETS: Array<{
|
||||
mode: OutboundMode
|
||||
field: string | undefined
|
||||
label: string
|
||||
}> = [
|
||||
{ mode: { kind: 'rules' }, field: undefined, label: 'RULE' },
|
||||
{ mode: { kind: 'direct' }, field: 'DIRECT', label: 'DIRECT' },
|
||||
{ mode: { kind: 'global' }, field: 'GLOBAL', label: 'GLOBAL' },
|
||||
]
|
||||
|
||||
function outboundOf(proxy: unknown): OutboundMode {
|
||||
if (typeof proxy !== 'string' || proxy === '') return { kind: 'rules' }
|
||||
if (proxy === 'DIRECT') return { kind: 'direct' }
|
||||
if (proxy === 'GLOBAL') return { kind: 'global' }
|
||||
return { kind: 'named', name: proxy }
|
||||
const preset = OUTBOUND_PRESETS.find((p) => p.field === proxy)
|
||||
return preset ? preset.mode : { kind: 'named', name: proxy }
|
||||
}
|
||||
|
||||
/** 把出站去向写回成配置里 proxy 字段该有的值。返回 undefined 表示不写这个字段。 */
|
||||
export function outboundToProxyField(mode: OutboundMode): string | undefined {
|
||||
if (mode.kind === 'rules') return undefined
|
||||
if (mode.kind === 'direct') return 'DIRECT'
|
||||
if (mode.kind === 'global') return 'GLOBAL'
|
||||
return mode.name
|
||||
/** 出站在界面上的名字。指名道姓的就是它自己的名字,其余用配置里的原词。 */
|
||||
export function outboundLabel(mode: OutboundMode): string {
|
||||
if (mode.kind === 'named') return mode.name
|
||||
return OUTBOUND_PRESETS.find((p) => p.mode.kind === mode.kind)?.label ?? mode.kind
|
||||
}
|
||||
|
||||
/**
|
||||
* 是不是「没有明确指定」那种状态。
|
||||
*
|
||||
* 界面上用来决定要不要弱化显示:RULE 是不写 proxy 字段时的默认结果,不是用户做出的
|
||||
* 选择,跟他明确选了 DIRECT 是两回事,不该长得一样。
|
||||
*/
|
||||
export function isUnsetOutbound(mode: OutboundMode): boolean {
|
||||
return mode.kind === 'rules'
|
||||
}
|
||||
|
||||
/** 入站类型在界面上怎么念。以配置里的原词为准,不另造描述。 */
|
||||
export function inboundTypeLabel(type: string): string {
|
||||
return type === '' ? '—' : type
|
||||
}
|
||||
|
||||
/**
|
||||
* 老式顶层端口实际绑在哪个地址上。
|
||||
*
|
||||
* 它没有自己的 listen 字段,地址是内核从两个全局开关推出来的
|
||||
* (内核源码 `listener/listener.go:709-718`):
|
||||
* allow-lan 关 → 127.0.0.1,只有本机连得上
|
||||
* allow-lan 开 → bind-address 指定的地址,写 * 或没写就是所有网卡
|
||||
* 界面上不能一律显示 0.0.0.0,那会让人以为局域网连得上而实际连不上。
|
||||
*/
|
||||
export function legacyListenAddress(data: ConfigObject): string {
|
||||
if (data['allow-lan'] !== true) return '127.0.0.1'
|
||||
const bind = data['bind-address']
|
||||
if (typeof bind !== 'string' || bind === '' || bind === '*') return '0.0.0.0'
|
||||
return bind
|
||||
}
|
||||
|
||||
export function collectInbounds(data: ConfigObject): Inbound[] {
|
||||
const result: Inbound[] = []
|
||||
const legacyListen = legacyListenAddress(data)
|
||||
|
||||
for (const { key, type } of LEGACY_PORTS) {
|
||||
const raw = data[key]
|
||||
@@ -89,13 +135,12 @@ export function collectInbounds(data: ConfigObject): Inbound[] {
|
||||
name: key,
|
||||
type,
|
||||
port: String(port),
|
||||
listen: null,
|
||||
listen: legacyListen,
|
||||
outbound: { kind: 'rules' },
|
||||
ruleSet: null,
|
||||
isDefault: true,
|
||||
legacyKey: key,
|
||||
listenerIndex: null,
|
||||
note: `由配置文件顶层的 ${key} 声明。这种写法没法单独指定出站,流量一律走分流规则。想让这个端口固定走某个出站,要把它改成命名入站。`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,13 +159,57 @@ export function collectInbounds(data: ConfigObject): Inbound[] {
|
||||
isDefault: false,
|
||||
legacyKey: null,
|
||||
listenerIndex: index,
|
||||
note: null,
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一路入站在内核连接快照里的 `inboundName`。
|
||||
*
|
||||
* 命名入站带的是配置里的 name 原词(内核源码 `adapter/inbound/addition.go:19`,
|
||||
* 名字来自 listener 配置的 name 字段)。老式顶层端口没有名字,内核给它们造了
|
||||
* `DEFAULT-协议` 这样的内部名(如 `listener/mixed/mixed.go:57` 的 DEFAULT-MIXED、
|
||||
* `listener/http/server.go:61` 的 DEFAULT-HTTP)。按这个名字才能把连接、吞吐这些
|
||||
* 运行时数据对回界面上的某一路。
|
||||
*/
|
||||
export function inboundStatKey(inbound: Inbound): string {
|
||||
if (!inbound.isDefault) return inbound.name
|
||||
return `DEFAULT-${inbound.type.toUpperCase()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 走规则表的流量最后交给谁。
|
||||
*
|
||||
* `MATCH` 匹配一切,所以第一条 MATCH 之后的规则都到不了,它就是兜底出站。
|
||||
* 没有 MATCH 时内核会直连,返回 null。
|
||||
*/
|
||||
export function fallbackTarget(data: ConfigObject): string | null {
|
||||
const rules = Array.isArray(data.rules) ? data.rules : []
|
||||
for (const rule of rules) {
|
||||
if (typeof rule !== 'string') continue
|
||||
const parts = rule.split(',').map((p) => p.trim())
|
||||
if (parts[0]?.toUpperCase() === 'MATCH' && parts[1]) return parts[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一路入站的流量最终交给哪个出站。
|
||||
*
|
||||
* 和 `inbound.outbound` 的区别在于「跟着规则走」会被继续解析下去:那种情况下真正决定
|
||||
* 去向的是规则表末尾的 MATCH。界面上要显示的是这个结果——用户想知道的是流量去哪,
|
||||
* 不是配置里那个字段写没写。
|
||||
*/
|
||||
export function resolveDestination(data: ConfigObject, inbound: Inbound): string | null {
|
||||
const mode = inbound.outbound
|
||||
if (mode.kind === 'direct') return 'DIRECT'
|
||||
if (mode.kind === 'global') return 'GLOBAL'
|
||||
if (mode.kind === 'named') return mode.name
|
||||
return fallbackTarget(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有可以被入站的出站字段引用的名字。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts'
|
||||
import type { RuntimeNode } from './outbound-chain.ts'
|
||||
|
||||
/**
|
||||
* 仿照真实那台机器:PROXY 手动选,它选中了按延迟自动挑的 AUTO,AUTO 挑了一条订阅线路。
|
||||
* PROXY 的成员是手写的 AUTO、DIRECT 加上订阅带进来的两条线路。
|
||||
*/
|
||||
const runtime: Record<string, RuntimeNode> = {
|
||||
PROXY: { type: 'Selector', now: 'AUTO', all: ['AUTO', 'DIRECT', '[vmess]DMIT', '[vless]Gomami'] },
|
||||
AUTO: { type: 'URLTest', now: '[vless]Gomami', all: ['[vmess]DMIT', '[vless]Gomami'] },
|
||||
'[vless]Gomami': { type: 'Vless' },
|
||||
'[vmess]DMIT': { type: 'Vmess' },
|
||||
DIRECT: { type: 'Direct' },
|
||||
}
|
||||
|
||||
describe('解析流量最终走到哪条线', () => {
|
||||
it('一路跟着当前选中走到底', () => {
|
||||
expect(resolveChain(runtime, 'PROXY')).toEqual(['PROXY', 'AUTO', '[vless]Gomami'])
|
||||
})
|
||||
|
||||
it('起点就是线路时链条只有它自己', () => {
|
||||
expect(resolveChain(runtime, '[vless]Gomami')).toEqual(['[vless]Gomami'])
|
||||
})
|
||||
|
||||
it('没有落脚点时是空链条', () => {
|
||||
expect(resolveChain(runtime, null)).toEqual([])
|
||||
})
|
||||
|
||||
it('落脚在一个内核不认识的名字上时也只返回它自己', () => {
|
||||
// 配置引用了一个不存在的出站时会走到这里,界面要能显示而不是崩掉
|
||||
expect(resolveChain(runtime, '不存在的组')).toEqual(['不存在的组'])
|
||||
})
|
||||
|
||||
it('策略组互相引用成环时停下来,不会转死', () => {
|
||||
const looped: Record<string, RuntimeNode> = {
|
||||
A: { type: 'Selector', now: 'B', all: ['B'] },
|
||||
B: { type: 'Selector', now: 'A', all: ['A'] },
|
||||
}
|
||||
expect(resolveChain(looped, 'A')).toEqual(['A', 'B'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('找出用户能换选中的那一层', () => {
|
||||
it('跳过自动决定的组,落在最后一个 Selector 上', () => {
|
||||
// 链条是 PROXY(Selector) → AUTO(URLTest) → 线路。能点的是 PROXY 不是 AUTO:
|
||||
// 内核对 URLTest 也接受手动指定,但那条线测不通时指定会临时让位,
|
||||
// 和「选了就一直是它」不是一回事,摆成同一个控件会骗人。
|
||||
const chain = resolveChain(runtime, 'PROXY')
|
||||
expect(lastSwitchableGroup(runtime, chain)).toEqual({
|
||||
group: 'PROXY',
|
||||
members: ['AUTO', 'DIRECT', '[vmess]DMIT', '[vless]Gomami'],
|
||||
now: 'AUTO',
|
||||
})
|
||||
})
|
||||
|
||||
it('有多个 Selector 时取最靠近线路的那个', () => {
|
||||
// 越靠近线路越接近「换线路」这个意图;前面的层换的是「走哪套策略」
|
||||
const nested: Record<string, RuntimeNode> = {
|
||||
外层: { type: 'Selector', now: '内层', all: ['内层'] },
|
||||
内层: { type: 'Selector', now: '线路A', all: ['线路A', '线路B'] },
|
||||
线路A: { type: 'Vmess' },
|
||||
}
|
||||
expect(lastSwitchableGroup(nested, resolveChain(nested, '外层'))?.group).toBe('内层')
|
||||
})
|
||||
|
||||
it('整条链都不是 Selector 时返回空', () => {
|
||||
const auto: Record<string, RuntimeNode> = {
|
||||
AUTO: { type: 'URLTest', now: '线路A', all: ['线路A'] },
|
||||
线路A: { type: 'Vmess' },
|
||||
}
|
||||
expect(lastSwitchableGroup(auto, resolveChain(auto, 'AUTO'))).toBeNull()
|
||||
})
|
||||
|
||||
it('链条上一个策略组都没有时返回空', () => {
|
||||
expect(lastSwitchableGroup(runtime, ['[vless]Gomami'])).toBeNull()
|
||||
expect(lastSwitchableGroup(runtime, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('Selector 存在但成员是空的,不算可切换', () => {
|
||||
// 筛选条件没匹配到任何线路时会出现这种组,给出一个点不动的列表只会让人困惑
|
||||
expect(lastSwitchableGroup({ 空组: { type: 'Selector', all: [], now: '' } }, ['空组'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 「这个端口现在到底走哪条线」——这个工具要回答的核心问题。
|
||||
*
|
||||
* 答案往往不是一跳能到的:一路入站落在 `PROXY` 组上,`PROXY` 选中了 `AUTO`,
|
||||
* `AUTO` 又按延迟挑了某条线路。只显示第一层等于没回答。
|
||||
*
|
||||
* 这里只做纯计算,输入是内核报上来的运行时状态,输出是名字数组。放在 shared 里
|
||||
* 是为了能被 `bun test` 覆盖——它带防环处理,而防环这种事写在组件的 computed 里
|
||||
* 就再也没人测得到。
|
||||
*/
|
||||
|
||||
/** 运行时状态里跟解析链路有关的部分。完整定义在 web/api.ts。 */
|
||||
export interface RuntimeNode {
|
||||
/** 出站类型。策略组是 Selector / URLTest / Fallback / LoadBalance 之一 */
|
||||
type?: string
|
||||
/** 策略组当前选中的成员。不是策略组就没有这个字段 */
|
||||
now?: string
|
||||
/** 策略组的成员列表 */
|
||||
all?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从落脚的出站一路跟着「当前选中」往下走。
|
||||
*
|
||||
* 返回的数组第一项是起点,最后一项是走到头的那个名字。起点为 null 时返回空数组。
|
||||
* 策略组理论上可以互相引用成环,遇到重复的名字就停下——内核自己会拒绝这种配置,
|
||||
* 但界面不能因为一份坏配置就转到卡死。
|
||||
*/
|
||||
export function resolveChain(
|
||||
runtime: Record<string, RuntimeNode>,
|
||||
start: string | null,
|
||||
): string[] {
|
||||
const hops: string[] = []
|
||||
let cursor = start
|
||||
while (cursor && !hops.includes(cursor)) {
|
||||
hops.push(cursor)
|
||||
cursor = runtime[cursor]?.now ?? null
|
||||
}
|
||||
return hops
|
||||
}
|
||||
|
||||
export interface SwitchableGroup {
|
||||
group: string
|
||||
members: string[]
|
||||
now: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 链条上最后一个 `Selector` 组。这一路入站没被 Patchbay 接管时,界面上能点的就是它。
|
||||
*
|
||||
* 取最后一个而不是第一个,是因为越靠近线路的那一层越接近「换线路」这个意图;
|
||||
* 前面的层换的是「走哪套策略」,那是另一回事。
|
||||
*
|
||||
* 只认 `Selector`,尽管内核对 `URLTest` 和 `Fallback` 也接受手动指定
|
||||
* (hub/route/proxies.go:85-90 检查的是 `SelectAble` 接口,不是类型)。原因是那两种的
|
||||
* 手动指定会自己失效:`URLTest` 选中的线路测不通时临时让位给最快的
|
||||
* (adapter/outboundgroup/urltest.go:103-116),`Fallback` 更狠,测不通一次就把手动指定
|
||||
* 永久清掉(fallback.go:117)。把它们和「选了就一直是它」摆成同一个控件会骗人。
|
||||
*/
|
||||
export function lastSwitchableGroup(
|
||||
runtime: Record<string, RuntimeNode>,
|
||||
chain: string[],
|
||||
): SwitchableGroup | null {
|
||||
for (let i = chain.length - 1; i >= 0; i -= 1) {
|
||||
const name = chain[i]!
|
||||
const node = runtime[name]
|
||||
if (!node?.all?.length || node.type !== 'Selector') continue
|
||||
return { group: name, members: node.all, now: node.now ?? '' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { isProviderFilePath } from './provider-files.ts'
|
||||
|
||||
describe('isProviderFilePath', () => {
|
||||
test('订阅缓存和规则清单的标准形状放行', () => {
|
||||
expect(isProviderFilePath('./providers/subs.yaml')).toBe(true)
|
||||
expect(isProviderFilePath('./providers/rules/ads.txt')).toBe(true)
|
||||
expect(isProviderFilePath('./providers/rules/直连清单.yaml')).toBe(true)
|
||||
})
|
||||
|
||||
test('目录逃逸和绝对路径全拦', () => {
|
||||
expect(isProviderFilePath('./providers/../config.yaml')).toBe(false)
|
||||
expect(isProviderFilePath('providers/subs.yaml')).toBe(false)
|
||||
expect(isProviderFilePath('/etc/mihomo/config.yaml')).toBe(false)
|
||||
expect(isProviderFilePath('./providers/rules/../../x.yaml')).toBe(false)
|
||||
expect(isProviderFilePath('./providers/a/b.yaml')).toBe(false)
|
||||
})
|
||||
|
||||
test('mrs 是二进制格式,不走文本上传', () => {
|
||||
expect(isProviderFilePath('./providers/rules/geo.mrs')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 上传的线路/规则清单文件允许写到哪。
|
||||
*
|
||||
* 服务端的写文件端点只认这两种形状——它们正是 providerCachePath()(订阅缓存)
|
||||
* 和 ruleProviderPath()(规则组清单)生成的路径。白名单式校验:文件名只许
|
||||
* 字母数字下划线连字符,杜绝 ../、绝对路径和多余的目录层级。
|
||||
* mrs 不在扩展名里:那是二进制格式,文本上传通道传不了。
|
||||
*/
|
||||
export function isProviderFilePath(path: string): boolean {
|
||||
return /^\.\/providers\/(rules\/)?[\p{L}\p{N}_-]+\.(yaml|txt)$/u.test(path)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
DEFAULT_HEALTH_CHECK,
|
||||
DEFAULT_INTERVAL,
|
||||
addFileSubscription,
|
||||
addSubscription,
|
||||
listSubscriptions,
|
||||
moveSubscription,
|
||||
providerNames,
|
||||
removeSubscription,
|
||||
renameSubscription,
|
||||
setSubscriptionHealthCheck,
|
||||
subscriptionPathConflict,
|
||||
subscriptionReferences,
|
||||
updateSubscription,
|
||||
} from './providers.ts'
|
||||
import {
|
||||
applySetup,
|
||||
chainProviderName,
|
||||
providerCachePath,
|
||||
pruneGenerated,
|
||||
syncGenerated,
|
||||
} from './inbound-groups.ts'
|
||||
import type { OutboundSetup } from './inbound-groups.ts'
|
||||
import { collectInbounds } from './inbounds.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
const URL = 'http://127.0.0.1:3001/token/download/V2House'
|
||||
|
||||
/** 一份带一个订阅的配置,形状仿照目标机器上那份。 */
|
||||
function base(): ConfigObject {
|
||||
return {
|
||||
'proxy-providers': {
|
||||
subs: {
|
||||
type: 'http',
|
||||
url: URL,
|
||||
path: './providers/subs.yaml',
|
||||
interval: 86400,
|
||||
'health-check': { enable: true, url: 'https://www.gstatic.com/generate_204', interval: 300 },
|
||||
},
|
||||
},
|
||||
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'], use: ['subs'] }],
|
||||
}
|
||||
}
|
||||
|
||||
const chainedSetup: OutboundSetup = {
|
||||
proxy: { sources: ['subs'], filter: null },
|
||||
chain: { sources: ['subs'], filter: null },
|
||||
}
|
||||
|
||||
describe('读出订阅列表', () => {
|
||||
it('读出地址、间隔和健康检查', () => {
|
||||
expect(listSubscriptions(base())).toEqual([
|
||||
{
|
||||
name: 'subs',
|
||||
type: 'http',
|
||||
path: './providers/subs.yaml',
|
||||
url: URL,
|
||||
interval: 86400,
|
||||
healthCheck: { url: 'https://www.gstatic.com/generate_204', interval: 300 },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('健康检查没写或没开都算未开启', () => {
|
||||
const data = base()
|
||||
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
delete subs['health-check']
|
||||
expect(listSubscriptions(data)[0]?.healthCheck).toBeNull()
|
||||
subs['health-check'] = { enable: false, url: 'https://example.com', interval: 60 }
|
||||
expect(listSubscriptions(data)[0]?.healthCheck).toBeNull()
|
||||
})
|
||||
|
||||
it('不含 Patchbay 生成的订阅副本', () => {
|
||||
const data = applySetup(
|
||||
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
||||
'test1',
|
||||
chainedSetup,
|
||||
)
|
||||
expect(listSubscriptions(data).map((s) => s.name)).toEqual(['subs'])
|
||||
// 副本还在,只是不列出来
|
||||
expect(providerNames(data)).toContain(chainProviderName('test1', 'subs'))
|
||||
})
|
||||
|
||||
it('没有 proxy-providers 段时是空列表', () => {
|
||||
expect(listSubscriptions({ mode: 'rule' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('新增订阅', () => {
|
||||
it('自动生成缓存路径、默认间隔和默认开启的健康检查', () => {
|
||||
const next = addSubscription({}, 'subs2', URL)
|
||||
expect(next['proxy-providers']).toEqual({
|
||||
subs2: {
|
||||
type: 'http',
|
||||
url: URL,
|
||||
path: './providers/subs2.yaml',
|
||||
interval: DEFAULT_INTERVAL,
|
||||
'health-check': DEFAULT_HEALTH_CHECK,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('不动已有的订阅', () => {
|
||||
const data = base()
|
||||
const next = addSubscription(data, 'subs2', URL)
|
||||
expect((next['proxy-providers'] as ConfigObject).subs).toEqual(
|
||||
(data['proxy-providers'] as ConfigObject).subs,
|
||||
)
|
||||
})
|
||||
|
||||
it('关掉自动更新时不写 interval 键', () => {
|
||||
const next = addSubscription({}, 'subs2', URL, null)
|
||||
const entry = (next['proxy-providers'] as ConfigObject).subs2 as ConfigObject
|
||||
expect('interval' in entry).toBe(false)
|
||||
})
|
||||
|
||||
it('从文件导入的订阅是 file 类型,没有地址和间隔', () => {
|
||||
const next = addFileSubscription({}, 'subs2')
|
||||
expect(next['proxy-providers']).toEqual({
|
||||
subs2: {
|
||||
type: 'file',
|
||||
path: './providers/subs2.yaml',
|
||||
'health-check': DEFAULT_HEALTH_CHECK,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('健康检查开关', () => {
|
||||
it('开启时缺的字段用默认值补齐', () => {
|
||||
const data = base()
|
||||
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
delete subs['health-check']
|
||||
const next = setSubscriptionHealthCheck(data, 'subs', true)
|
||||
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
expect(entry['health-check']).toEqual(DEFAULT_HEALTH_CHECK)
|
||||
})
|
||||
|
||||
it('关闭只翻 enable,手写的 url 和 interval 留着', () => {
|
||||
const data = base()
|
||||
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
subs['health-check'] = { enable: true, url: 'https://example.com/ping', interval: 60 }
|
||||
const next = setSubscriptionHealthCheck(data, 'subs', false)
|
||||
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
expect(entry['health-check']).toEqual({
|
||||
enable: false,
|
||||
url: 'https://example.com/ping',
|
||||
interval: 60,
|
||||
})
|
||||
// 再开启时它们原样回来
|
||||
const back = setSubscriptionHealthCheck(next, 'subs', true)
|
||||
expect(listSubscriptions(back)[0]?.healthCheck).toEqual({
|
||||
url: 'https://example.com/ping',
|
||||
interval: 60,
|
||||
})
|
||||
})
|
||||
|
||||
it('本来就没写又要关闭时不做改动', () => {
|
||||
const data = base()
|
||||
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
delete subs['health-check']
|
||||
expect(setSubscriptionHealthCheck(data, 'subs', false)).toBe(data)
|
||||
})
|
||||
|
||||
it('订阅不存在时不做改动', () => {
|
||||
const data = base()
|
||||
expect(setSubscriptionHealthCheck(data, 'ghost', true)).toBe(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('订阅改名', () => {
|
||||
it('改映射键并同步策略组的 use 引用,path 不动', () => {
|
||||
const next = renameSubscription(base(), 'subs', 'subs-new')
|
||||
const providers = next['proxy-providers'] as ConfigObject
|
||||
expect(Object.keys(providers)).toEqual(['subs-new'])
|
||||
expect((providers['subs-new'] as ConfigObject).path).toBe('./providers/subs.yaml')
|
||||
const group = (next['proxy-groups'] as ConfigObject[])[0]!
|
||||
expect(group.use).toEqual(['subs-new'])
|
||||
})
|
||||
|
||||
it('保持条目在映射里的位置', () => {
|
||||
const data = addSubscription(addSubscription(base(), 'a', URL), 'b', URL)
|
||||
const next = renameSubscription(data, 'a', 'z')
|
||||
expect(Object.keys(next['proxy-providers'] as ConfigObject)).toEqual(['subs', 'z', 'b'])
|
||||
})
|
||||
|
||||
it('改名后保存链路按新名字重建链式副本', () => {
|
||||
const data = applySetup(
|
||||
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
||||
'test1',
|
||||
chainedSetup,
|
||||
)
|
||||
const renamed = renameSubscription(data, 'subs', 'subs2')
|
||||
// 页面 commit() 每次落盘前都跑这两步
|
||||
const synced = pruneGenerated(syncGenerated(renamed, collectInbounds(renamed)))
|
||||
expect(providerNames(synced)).toContain(chainProviderName('test1', 'subs2'))
|
||||
expect(providerNames(synced)).not.toContain(chainProviderName('test1', 'subs'))
|
||||
// 引用检查也要认得出新名字,删除保护不能因为改名失效
|
||||
expect(subscriptionReferences(synced, 'subs2')).not.toEqual([])
|
||||
})
|
||||
|
||||
it('原名不存在、新名已占用或没改时都不做改动', () => {
|
||||
const data = base()
|
||||
expect(renameSubscription(data, 'ghost', 'x')).toBe(data)
|
||||
const withX = addSubscription(data, 'x', URL)
|
||||
expect(renameSubscription(withX, 'subs', 'x')).toBe(withX)
|
||||
expect(renameSubscription(data, 'subs', 'subs')).toBe(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('编辑订阅', () => {
|
||||
it('只改地址和间隔,其余字段原样保留', () => {
|
||||
const next = updateSubscription(base(), 'subs', 'http://example.com/new', 3600)
|
||||
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
expect(entry.url).toBe('http://example.com/new')
|
||||
expect(entry.interval).toBe(3600)
|
||||
expect(entry.path).toBe('./providers/subs.yaml')
|
||||
expect(entry['health-check']).toEqual({
|
||||
enable: true,
|
||||
url: 'https://www.gstatic.com/generate_204',
|
||||
interval: 300,
|
||||
})
|
||||
})
|
||||
|
||||
it('订阅不存在时不做改动', () => {
|
||||
const data = base()
|
||||
expect(updateSubscription(data, 'ghost', URL, 1)).toBe(data)
|
||||
})
|
||||
|
||||
it('关掉自动更新时把已有的 interval 键去掉', () => {
|
||||
const next = updateSubscription(base(), 'subs', URL, null)
|
||||
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
|
||||
expect('interval' in entry).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('删除订阅', () => {
|
||||
it('从映射里去掉', () => {
|
||||
const data = addSubscription(base(), 'subs2', URL)
|
||||
const next = removeSubscription(data, 'subs2')
|
||||
expect(providerNames(next)).toEqual(['subs'])
|
||||
})
|
||||
|
||||
it('删到最后一个时整个键去掉,不留空映射', () => {
|
||||
const next = removeSubscription(base(), 'subs')
|
||||
expect('proxy-providers' in next).toBe(false)
|
||||
})
|
||||
|
||||
it('订阅不存在时不做改动', () => {
|
||||
const data = base()
|
||||
expect(removeSubscription(data, 'ghost')).toBe(data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('删除前的引用检查', () => {
|
||||
it('找出 use 它的策略组', () => {
|
||||
expect(subscriptionReferences(base(), 'subs')).toEqual(['PROXY'])
|
||||
})
|
||||
|
||||
it('没人在用时是空列表', () => {
|
||||
const data = base()
|
||||
;(data['proxy-groups'] as ConfigObject[])[0] = { name: 'PROXY', type: 'select', proxies: ['DIRECT'] }
|
||||
expect(subscriptionReferences(data, 'subs')).toEqual([])
|
||||
})
|
||||
|
||||
it('链式代理的专属组 use 的是副本,也算引用原订阅', () => {
|
||||
const data = applySetup(
|
||||
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
||||
'test1',
|
||||
chainedSetup,
|
||||
)
|
||||
const refs = subscriptionReferences(data, 'subs')
|
||||
// 入口组直接 use 原订阅,三个专属组 use 副本,两边都要认出来
|
||||
expect(refs).toContain('patchbay:test1 链式')
|
||||
expect(refs).toContain('patchbay:test1')
|
||||
})
|
||||
|
||||
it('订阅不存在时是空列表', () => {
|
||||
expect(subscriptionReferences(base(), 'ghost')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('调整订阅顺序', () => {
|
||||
/** 三个用户订阅,外加一份接在中间的订阅副本。 */
|
||||
function three(): ConfigObject {
|
||||
let data: ConfigObject = {}
|
||||
for (const name of ['A', 'B', 'C']) data = addSubscription(data, name, `https://${name}.example`)
|
||||
return data
|
||||
}
|
||||
|
||||
it('上移和下移交换相邻两个订阅', () => {
|
||||
expect(listSubscriptions(moveSubscription(three(), 'B', -1)).map((s) => s.name)).toEqual([
|
||||
'B',
|
||||
'A',
|
||||
'C',
|
||||
])
|
||||
expect(listSubscriptions(moveSubscription(three(), 'B', 1)).map((s) => s.name)).toEqual([
|
||||
'A',
|
||||
'C',
|
||||
'B',
|
||||
])
|
||||
})
|
||||
|
||||
it('到头了原样返回,配置对象不变', () => {
|
||||
const data = three()
|
||||
expect(moveSubscription(data, 'A', -1)).toBe(data)
|
||||
expect(moveSubscription(data, 'C', 1)).toBe(data)
|
||||
expect(moveSubscription(data, '不存在', 1)).toBe(data)
|
||||
})
|
||||
|
||||
it('夹在中间的订阅副本留在原地', () => {
|
||||
const withCopy = applySetup(
|
||||
{ ...three(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
|
||||
'test1',
|
||||
{ proxy: { sources: ['B'], filter: null }, chain: { sources: ['B'], filter: null } },
|
||||
)
|
||||
const copy = chainProviderName('test1', 'B')
|
||||
const before = Object.keys(withCopy['proxy-providers'] as ConfigObject).indexOf(copy)
|
||||
const after = moveSubscription(withCopy, 'A', 1)
|
||||
expect(listSubscriptions(after).map((s) => s.name)).toEqual(['B', 'A', 'C'])
|
||||
expect(Object.keys(after['proxy-providers'] as ConfigObject).indexOf(copy)).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('缓存路径撞名', () => {
|
||||
it('名字只差符号时净化成同一个路径,能认出来', () => {
|
||||
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
||||
const path = providerCachePath('订阅/A')
|
||||
expect(path).toBe(providerCachePath('订阅 A'))
|
||||
expect(subscriptionPathConflict(data, '订阅/A', path)).toBe('订阅 A')
|
||||
})
|
||||
|
||||
it('不同路径、或者就是自己,都不算撞', () => {
|
||||
const data = addSubscription({}, '订阅 A', 'https://a.example/sub')
|
||||
expect(subscriptionPathConflict(data, 'B', providerCachePath('B'))).toBeNull()
|
||||
expect(subscriptionPathConflict(data, '订阅 A', providerCachePath('订阅 A'))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 订阅的读写。
|
||||
*
|
||||
* 配置文件里的 `proxy-providers` 段:一个名字对一份声明,内核定时从地址拉回一批线路。
|
||||
* 这段里除了用户自己加的订阅,还有 Patchbay 为链式代理生成的副本(带 `patchbay:` 前缀)。
|
||||
* 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责,
|
||||
* 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。
|
||||
*
|
||||
* 改名必须走 renameSubscription:名字被策略组的 `use:` 和生成的副本引用,改名和
|
||||
* 同步引用要在同一次变换里完成,散开做漏一处就是断引用。
|
||||
*/
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { isGenerated, providerCachePath } from './inbound-groups.ts'
|
||||
|
||||
/** 新增订阅时的默认更新间隔,秒。一天一次。 */
|
||||
export const DEFAULT_INTERVAL = 86400
|
||||
|
||||
/**
|
||||
* 新增订阅或开启健康检查时写入的默认值。
|
||||
*
|
||||
* `url` 必须写:内核没有默认检查地址,为空时 interval 被强制归零、检查不会运行
|
||||
* (内核源码 adapter/provider/healthcheck.go:196-199)。`interval` 与内核在
|
||||
* enable 时的默认一致(adapter/provider/parser.go:66-68),写死一个更稳。
|
||||
*/
|
||||
export const DEFAULT_HEALTH_CHECK = {
|
||||
enable: true,
|
||||
url: 'https://www.gstatic.com/generate_204',
|
||||
interval: 300,
|
||||
}
|
||||
|
||||
/** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */
|
||||
export interface Subscription {
|
||||
name: string
|
||||
/** http 定时从地址拉取;file 读本机文件,内核靠文件监视跟进变化 */
|
||||
type: 'http' | 'file'
|
||||
url: string
|
||||
/** 线路文件的路径(http 类型是缓存,file 类型是数据本体) */
|
||||
path: string | null
|
||||
/**
|
||||
* 更新间隔,秒。null 表示配置里没写——内核只在 interval > 0 时才起定时
|
||||
* 拉取循环(内核源码 component/resource/fetcher.go:226-228),所以
|
||||
* 「没写」就是「不自动更新」,界面上它是个开关而不是必填数字。
|
||||
*/
|
||||
interval: number | null
|
||||
/** 健康检查。没写或 enable 不是 true 都算未开启 */
|
||||
healthCheck: { url: string | null; interval: number | null } | null
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is ConfigObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function providersOf(data: ConfigObject): ConfigObject {
|
||||
return isPlainObject(data['proxy-providers']) ? data['proxy-providers'] : {}
|
||||
}
|
||||
|
||||
function groupsOf(data: ConfigObject): ConfigObject[] {
|
||||
return (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []).filter(isPlainObject)
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
}
|
||||
|
||||
/** 所有用户订阅,按配置里的顺序。不含生成的副本。 */
|
||||
export function listSubscriptions(data: ConfigObject): Subscription[] {
|
||||
const result: Subscription[] = []
|
||||
for (const [name, entry] of Object.entries(providersOf(data))) {
|
||||
if (isGenerated(name) || !isPlainObject(entry)) continue
|
||||
const hc = isPlainObject(entry['health-check']) ? entry['health-check'] : null
|
||||
result.push({
|
||||
name,
|
||||
type: entry.type === 'file' ? 'file' : 'http',
|
||||
path: typeof entry.path === 'string' ? entry.path : null,
|
||||
url: typeof entry.url === 'string' ? entry.url : '',
|
||||
interval: typeof entry.interval === 'number' ? entry.interval : null,
|
||||
healthCheck:
|
||||
hc !== null && hc.enable === true
|
||||
? {
|
||||
url: typeof hc.url === 'string' ? hc.url : null,
|
||||
interval: typeof hc.interval === 'number' ? hc.interval : null,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* `proxy-providers` 里的所有名字,含生成的副本。
|
||||
* 新增订阅挡重名要用全部键:它是映射,和用户看不见的副本撞名也会被静默丢掉。
|
||||
*/
|
||||
export function providerNames(data: ConfigObject): string[] {
|
||||
return Object.keys(providersOf(data))
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增一个订阅。
|
||||
*
|
||||
* `path` 自动给出确定的缓存文件路径,不让用户填:不写的话内核按 url 哈希自己生成一个,
|
||||
* 那个路径 Patchbay 算不出来,链式代理的副本就没法靠「读同一个缓存文件」认来源
|
||||
* (见 inbound-groups.ts 的 applySetup)。
|
||||
*
|
||||
* `interval` 为 null 时不写这个键,内核就不自动更新(见 Subscription.interval 的注)。
|
||||
*/
|
||||
export function addSubscription(
|
||||
data: ConfigObject,
|
||||
name: string,
|
||||
url: string,
|
||||
interval: number | null = DEFAULT_INTERVAL,
|
||||
): ConfigObject {
|
||||
const next = { ...data }
|
||||
next['proxy-providers'] = {
|
||||
...providersOf(data),
|
||||
[name]: {
|
||||
type: 'http',
|
||||
url,
|
||||
path: providerCachePath(name),
|
||||
...(interval !== null ? { interval } : {}),
|
||||
'health-check': { ...DEFAULT_HEALTH_CHECK },
|
||||
},
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 新订阅的缓存路径是否和现有订阅的撞车。
|
||||
*
|
||||
* 名字查重挡不住这种:路径由名字净化而来,「订阅 A」和「订阅/A」是两个名字、
|
||||
* 同一个 `providers/订阅-A.yaml`,两个订阅会互相覆盖对方的缓存文件。
|
||||
* 返回撞上的那个订阅名,没撞返回 null。
|
||||
*/
|
||||
export function subscriptionPathConflict(
|
||||
data: ConfigObject,
|
||||
selfName: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
for (const [name, decl] of Object.entries(providersOf(data))) {
|
||||
if (name === selfName) continue
|
||||
// 没写 path 的声明由内核按 url 哈希生成路径,和这里的净化路径撞不上
|
||||
if ((decl as ConfigObject).path === path) return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增一个来自本机文件的订阅。数据就在 path 指的文件里(内容由上传流程先写好),
|
||||
* 内核靠文件监视跟进变化,没有地址也没有更新间隔。
|
||||
*/
|
||||
export function addFileSubscription(data: ConfigObject, name: string): ConfigObject {
|
||||
const next = { ...data }
|
||||
next['proxy-providers'] = {
|
||||
...providersOf(data),
|
||||
[name]: {
|
||||
type: 'file',
|
||||
path: providerCachePath(name),
|
||||
'health-check': { ...DEFAULT_HEALTH_CHECK },
|
||||
},
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** 改订阅的地址和更新间隔。其余字段(缓存路径、健康检查、筛选)原样保留。 */
|
||||
export function updateSubscription(
|
||||
data: ConfigObject,
|
||||
name: string,
|
||||
url: string,
|
||||
interval: number | null,
|
||||
): ConfigObject {
|
||||
const entry = providersOf(data)[name]
|
||||
if (!isPlainObject(entry)) return data
|
||||
const updated: ConfigObject = { ...entry, url }
|
||||
if (interval === null) delete updated.interval
|
||||
else updated.interval = interval
|
||||
const next = { ...data }
|
||||
next['proxy-providers'] = {
|
||||
...providersOf(data),
|
||||
[name]: updated,
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 开关一个订阅的健康检查。
|
||||
*
|
||||
* 关闭写 `enable: false` 而不是删掉整个键:用户手写的 url/interval 要留着,
|
||||
* 下次开启时还用它们。开启时缺的字段用默认值补齐——url 不能缺,内核没有默认
|
||||
* 检查地址(见 DEFAULT_HEALTH_CHECK 的注)。
|
||||
*/
|
||||
export function setSubscriptionHealthCheck(
|
||||
data: ConfigObject,
|
||||
name: string,
|
||||
enable: boolean,
|
||||
): ConfigObject {
|
||||
const entry = providersOf(data)[name]
|
||||
if (!isPlainObject(entry)) return data
|
||||
const existing = isPlainObject(entry['health-check']) ? entry['health-check'] : null
|
||||
// 本来就没写又要关闭,不必写一个 enable: false 的完整声明进去
|
||||
if (!enable && existing === null) return data
|
||||
const next = { ...data }
|
||||
next['proxy-providers'] = {
|
||||
...providersOf(data),
|
||||
[name]: { ...entry, 'health-check': { ...DEFAULT_HEALTH_CHECK, ...existing, enable } },
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 改订阅的名字,并同步所有策略组 `use:` 里的引用。
|
||||
*
|
||||
* 缓存路径 `path` 保持不变:file 类型的数据本体就在旧路径上,链式代理的副本靠
|
||||
* 「和原订阅同一个 path」认来源(见 inbound-groups.ts),路径一动这两件事都得另做迁移。
|
||||
* 生成的副本这里不碰:写配置的链路每次都跑 syncGenerated/pruneGenerated,
|
||||
* 会按新名字重建副本、清掉旧的。
|
||||
*/
|
||||
export function renameSubscription(
|
||||
data: ConfigObject,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): ConfigObject {
|
||||
const providers = providersOf(data)
|
||||
if (oldName === newName || !(oldName in providers) || newName in providers) return data
|
||||
|
||||
const next = { ...data }
|
||||
// 逐项重建以保住条目在映射里的位置,diff 才不会把整段重排
|
||||
next['proxy-providers'] = Object.fromEntries(
|
||||
Object.entries(providers).map(([name, entry]) => [name === oldName ? newName : name, entry]),
|
||||
)
|
||||
|
||||
if (Array.isArray(data['proxy-groups'])) {
|
||||
next['proxy-groups'] = data['proxy-groups'].map((group) => {
|
||||
if (!isPlainObject(group) || !Array.isArray(group.use)) return group
|
||||
if (!group.use.includes(oldName)) return group
|
||||
return { ...group, use: group.use.map((item) => (item === oldName ? newName : item)) }
|
||||
})
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个订阅在列表里上移或下移一位。
|
||||
*
|
||||
* 顺序只是呈现顺序,内核不看 `proxy-providers` 里条目的先后。交换的是两个用户订阅在
|
||||
* 映射里的位置,中间夹着的副本留在原地——它们不在列表上,跟着挪只会把 diff 撑大。
|
||||
* 已经在头(尾)还要上(下)移时原样返回,调用方据此不发起保存。
|
||||
*/
|
||||
export function moveSubscription(
|
||||
data: ConfigObject,
|
||||
name: string,
|
||||
delta: -1 | 1,
|
||||
): ConfigObject {
|
||||
const entries = Object.entries(providersOf(data))
|
||||
const keys = entries.map(([key]) => key)
|
||||
const mine = keys.filter((key) => !isGenerated(key))
|
||||
const at = mine.indexOf(name)
|
||||
const target = mine[at + delta]
|
||||
if (at < 0 || target === undefined) return data
|
||||
|
||||
const from = keys.indexOf(name)
|
||||
const onto = keys.indexOf(target)
|
||||
const order = [...entries]
|
||||
order[from] = entries[onto]!
|
||||
order[onto] = entries[from]!
|
||||
|
||||
const next = { ...data }
|
||||
next['proxy-providers'] = Object.fromEntries(order)
|
||||
return next
|
||||
}
|
||||
|
||||
/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */
|
||||
export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
|
||||
const providers = { ...providersOf(data) }
|
||||
if (!(name in providers)) return data
|
||||
delete providers[name]
|
||||
const next = { ...data }
|
||||
if (Object.keys(providers).length === 0) delete next['proxy-providers']
|
||||
else next['proxy-providers'] = providers
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 哪些策略组在引用这个订阅。删除前必须先查:被引用的订阅删了,那些组的 `use:` 就空了,
|
||||
* 而空组会让指向它的入站无路可走。
|
||||
*
|
||||
* 引用有两种,第二种容易漏:
|
||||
* 直接 组的 `use:` 里有这个订阅。链式代理的入口组就是这么引原订阅的
|
||||
* 间接 开了链式代理的入站,它的专属组 `use:` 的是订阅副本;副本靠和原订阅
|
||||
* 共用缓存文件(`path` 相同)认来源,名字不可靠解析(见 inbound-groups.ts)
|
||||
*/
|
||||
export function subscriptionReferences(data: ConfigObject, name: string): string[] {
|
||||
const target = providersOf(data)[name]
|
||||
if (!isPlainObject(target)) return []
|
||||
|
||||
const copies = new Set<string>()
|
||||
if (typeof target.path === 'string') {
|
||||
for (const [copyName, entry] of Object.entries(providersOf(data))) {
|
||||
if (isGenerated(copyName) && isPlainObject(entry) && entry.path === target.path) {
|
||||
copies.add(copyName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groupsOf(data)
|
||||
.filter((group) => {
|
||||
const use = stringList(group.use)
|
||||
return use.includes(name) || use.some((u) => copies.has(u))
|
||||
})
|
||||
.map((group) => String(group.name))
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
buildRule,
|
||||
findRuleIssues,
|
||||
firstMatchIndex,
|
||||
inferRuleFormat,
|
||||
insertBeforeMatch,
|
||||
isRawOnlyRule,
|
||||
isStructuredEditable,
|
||||
missingLanRules,
|
||||
moveRule,
|
||||
parseIniRulesets,
|
||||
parseRule,
|
||||
planIniImport,
|
||||
ruleProviderPath,
|
||||
ruleProviderPathConflict,
|
||||
ruleSetReferences,
|
||||
setRuleProviders,
|
||||
setRules,
|
||||
subRuleSetReferences,
|
||||
} from './rules.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
describe('规则字符串的拆分与拼装', () => {
|
||||
it('常见的三段规则', () => {
|
||||
expect(parseRule('DOMAIN-SUFFIX,youtube.com,PROXY')).toEqual({
|
||||
type: 'DOMAIN-SUFFIX',
|
||||
payload: 'youtube.com',
|
||||
target: 'PROXY',
|
||||
extras: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('MATCH 没有载荷', () => {
|
||||
expect(parseRule('MATCH,PROXY')).toEqual({
|
||||
type: 'MATCH',
|
||||
payload: null,
|
||||
target: 'PROXY',
|
||||
extras: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('选项尾部原样保留', () => {
|
||||
const parsed = parseRule('IP-CIDR,127.0.0.0/8,DIRECT,no-resolve')
|
||||
expect(parsed.extras).toEqual(['no-resolve'])
|
||||
// 改目标不能丢掉选项
|
||||
expect(buildRule({ ...parsed, target: 'REJECT' })).toBe('IP-CIDR,127.0.0.0/8,REJECT,no-resolve')
|
||||
})
|
||||
|
||||
it('拆开再拼回原样', () => {
|
||||
for (const rule of [
|
||||
'DOMAIN-SUFFIX,youtube.com,PROXY',
|
||||
'MATCH,PROXY',
|
||||
'IP-CIDR,127.0.0.0/8,DIRECT,no-resolve',
|
||||
'RULE-SET,ads,REJECT',
|
||||
'AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY',
|
||||
]) {
|
||||
expect(buildRule(parseRule(rule))).toBe(rule)
|
||||
}
|
||||
})
|
||||
|
||||
it('逻辑规则的载荷里有逗号也不被拆开', () => {
|
||||
const parsed = parseRule('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')
|
||||
expect(parsed.payload).toBe('((DOMAIN-SUFFIX,a.com),(DST-PORT,443))')
|
||||
expect(parsed.target).toBe('PROXY')
|
||||
})
|
||||
|
||||
it('正则类规则的顶层逗号不拆坏:最后一段是目标,其余全归载荷', () => {
|
||||
// 内核对 DOMAIN-REGEX 这几类按「载荷可含逗号、不许带选项」读(rules/common/base.go:47-79)
|
||||
expect(parseRule('DOMAIN-REGEX,^(a|b),c$,PROXY')).toEqual({
|
||||
type: 'DOMAIN-REGEX',
|
||||
payload: '^(a|b),c$',
|
||||
target: 'PROXY',
|
||||
extras: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('结构化编辑只给常见形状,逻辑规则、子规则和正则类按原文编辑', () => {
|
||||
expect(isStructuredEditable('DOMAIN-SUFFIX,youtube.com,PROXY')).toBe(true)
|
||||
expect(isStructuredEditable('MATCH,PROXY')).toBe(true)
|
||||
expect(isStructuredEditable('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')).toBe(false)
|
||||
expect(isStructuredEditable('SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1')).toBe(false)
|
||||
expect(isStructuredEditable('DOMAIN-REGEX,^ads?,b$,REJECT')).toBe(false)
|
||||
expect(isStructuredEditable('孤零零一段')).toBe(false)
|
||||
})
|
||||
|
||||
it('只认整行编辑的类型;空行和没写完的行不算,表单要能接回去', () => {
|
||||
expect(isRawOnlyRule('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')).toBe(true)
|
||||
expect(isRawOnlyRule('DOMAIN-REGEX,^ads?,b$,REJECT')).toBe(true)
|
||||
expect(isRawOnlyRule('')).toBe(false)
|
||||
expect(isRawOnlyRule('DOMAIN,youtube.com')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则表的顺序', () => {
|
||||
const rules = ['DOMAIN-SUFFIX,a.com,DIRECT', 'MATCH,PROXY', 'DOMAIN-SUFFIX,b.com,REJECT']
|
||||
|
||||
it('第一条 MATCH 之后的规则不可达', () => {
|
||||
expect(firstMatchIndex(rules)).toBe(1)
|
||||
expect(firstMatchIndex(['DOMAIN-SUFFIX,a.com,DIRECT'])).toBe(-1)
|
||||
})
|
||||
|
||||
it('新规则插在第一条 MATCH 之前', () => {
|
||||
expect(insertBeforeMatch(rules, ['GEOIP,CN,DIRECT'])).toEqual([
|
||||
'DOMAIN-SUFFIX,a.com,DIRECT',
|
||||
'GEOIP,CN,DIRECT',
|
||||
'MATCH,PROXY',
|
||||
'DOMAIN-SUFFIX,b.com,REJECT',
|
||||
])
|
||||
})
|
||||
|
||||
it('没有 MATCH 时加在末尾', () => {
|
||||
expect(insertBeforeMatch(['GEOIP,CN,DIRECT'], ['MATCH,PROXY'])).toEqual([
|
||||
'GEOIP,CN,DIRECT',
|
||||
'MATCH,PROXY',
|
||||
])
|
||||
})
|
||||
|
||||
it('上移下移只交换相邻两条,其余不动', () => {
|
||||
expect(moveRule(rules, 0, 1)).toEqual([
|
||||
'MATCH,PROXY',
|
||||
'DOMAIN-SUFFIX,a.com,DIRECT',
|
||||
'DOMAIN-SUFFIX,b.com,REJECT',
|
||||
])
|
||||
expect(moveRule(rules, 0, -1)).toBe(rules)
|
||||
expect(moveRule(rules, 2, 1)).toBe(rules)
|
||||
})
|
||||
})
|
||||
|
||||
describe('断引用', () => {
|
||||
it('目标出站不存在和规则组不存在都标出来', () => {
|
||||
const rules = [
|
||||
'DOMAIN-SUFFIX,a.com,NOPE',
|
||||
'RULE-SET,ads,REJECT',
|
||||
'RULE-SET,gone,DIRECT',
|
||||
'SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1',
|
||||
'MATCH,PROXY',
|
||||
]
|
||||
const issues = findRuleIssues(rules, new Set(['PROXY', 'REJECT', 'DIRECT']), new Set(['ads']))
|
||||
expect(issues).toEqual([
|
||||
{ index: 0, kind: 'target', name: 'NOPE' },
|
||||
{ index: 2, kind: 'rule-set', name: 'gone' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('内网直连预设', () => {
|
||||
it('空表全缺', () => {
|
||||
expect(missingLanRules([])).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('已管住的网段不重复加,哪怕写法不完全一样', () => {
|
||||
const existing = ['IP-CIDR,192.168.0.0/16,DIRECT', 'MATCH,PROXY']
|
||||
const missing = missingLanRules(existing)
|
||||
expect(missing).toHaveLength(7)
|
||||
expect(missing.some((r) => r.includes('192.168.0.0/16'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('删除规则组前找引用', () => {
|
||||
it('列出 rules 里引用它的规则下标', () => {
|
||||
const rules = ['RULE-SET,ads,REJECT', 'DOMAIN-SUFFIX,a.com,DIRECT', 'RULE-SET,ads,PROXY']
|
||||
expect(ruleSetReferences(rules, 'ads')).toEqual([0, 2])
|
||||
expect(ruleSetReferences(rules, 'other')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('配置形状', () => {
|
||||
it('rules 空了删掉整个键', () => {
|
||||
const data: ConfigObject = { rules: ['MATCH,PROXY'] }
|
||||
setRules(data, ['A,B,C'])
|
||||
expect(data.rules).toEqual(['A,B,C'])
|
||||
setRules(data, [])
|
||||
expect('rules' in data).toBe(false)
|
||||
})
|
||||
|
||||
it('rule-providers 空了删掉整个键', () => {
|
||||
const data: ConfigObject = {}
|
||||
setRuleProviders(data, { ads: { type: 'http' } })
|
||||
expect(Object.keys(data['rule-providers'] as ConfigObject)).toEqual(['ads'])
|
||||
setRuleProviders(data, {})
|
||||
expect('rule-providers' in data).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ini 的 ruleset= 行解析', () => {
|
||||
const ini = `
|
||||
[custom]
|
||||
; 注释行不算
|
||||
;ruleset=🎯 全球直连,[]GEOIP,LAN
|
||||
ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list
|
||||
ruleset=🛑 全球拦截,https://example.com/BanAD.list?token=1
|
||||
ruleset=🎯 全球直连,https://example.com/GoogleCN.list
|
||||
ruleset=🍎 苹果服务,Apple.list
|
||||
ruleset=🎯 全球直连,[]GEOIP,CN
|
||||
ruleset=🐟 漏网之鱼,[]FINAL
|
||||
custom_proxy_group=🚀 节点选择\`select\`[]AUTO
|
||||
`
|
||||
|
||||
it('URL、本地路径、内联 GEOIP 和 FINAL 都认出来', () => {
|
||||
expect(parseIniRulesets(ini)).toEqual([
|
||||
{ target: '🎯 全球直连', kind: 'url', source: 'https://example.com/LocalAreaNetwork.list' },
|
||||
{ target: '🛑 全球拦截', kind: 'url', source: 'https://example.com/BanAD.list?token=1' },
|
||||
{ target: '🎯 全球直连', kind: 'url', source: 'https://example.com/GoogleCN.list' },
|
||||
{ target: '🍎 苹果服务', kind: 'file', source: 'Apple.list' },
|
||||
{ target: '🎯 全球直连', kind: 'geoip', source: 'CN' },
|
||||
{ target: '🐟 漏网之鱼', kind: 'final', source: '' },
|
||||
])
|
||||
})
|
||||
|
||||
it('custom_proxy_group 和残缺行不产生条目', () => {
|
||||
expect(parseIniRulesets('ruleset=\nruleset=只有目标,\ncustom_proxy_group=x`select`y')).toEqual([])
|
||||
})
|
||||
|
||||
it('GEOIP 类别后的附加参数不并进类别名,生成规则时放在目标之后', () => {
|
||||
const entries = parseIniRulesets('ruleset=🎯 全球直连,[]GEOIP,CN,no-resolve')
|
||||
expect(entries).toEqual([
|
||||
{ target: '🎯 全球直连', kind: 'geoip', source: 'CN', flags: ['no-resolve'] },
|
||||
])
|
||||
const plan = planIniImport(entries, { '🎯 全球直连': 'DIRECT' }, new Set())
|
||||
expect(plan.rules).toEqual(['GEOIP,CN,DIRECT,no-resolve'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ini 导入计划', () => {
|
||||
const entries = parseIniRulesets(`
|
||||
ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list
|
||||
ruleset=🎯 全球直连,https://example.com/GoogleCN.list
|
||||
ruleset=🎯 全球直连,[]GEOIP,CN
|
||||
ruleset=🐟 漏网之鱼,[]FINAL
|
||||
`)
|
||||
|
||||
it('每行一个规则组加一条规则骨架,FINAL 落在表末尾', () => {
|
||||
const plan = planIniImport(
|
||||
entries,
|
||||
{ '🎯 全球直连': 'DIRECT', '🐟 漏网之鱼': 'PROXY' },
|
||||
new Set(),
|
||||
)
|
||||
expect(Object.keys(plan.providers)).toEqual(['LocalAreaNetwork', 'GoogleCN'])
|
||||
expect(plan.providers.LocalAreaNetwork).toEqual({
|
||||
type: 'http',
|
||||
behavior: 'classical',
|
||||
url: 'https://example.com/LocalAreaNetwork.list',
|
||||
path: './providers/rules/LocalAreaNetwork.txt',
|
||||
format: 'text',
|
||||
interval: 86400,
|
||||
})
|
||||
expect(plan.rules).toEqual([
|
||||
'RULE-SET,LocalAreaNetwork,DIRECT',
|
||||
'RULE-SET,GoogleCN,DIRECT',
|
||||
'GEOIP,CN,DIRECT',
|
||||
])
|
||||
expect(plan.finals).toEqual(['MATCH,PROXY'])
|
||||
})
|
||||
|
||||
it('同一个来源出现多次只声明一个规则组', () => {
|
||||
const plan = planIniImport(
|
||||
[
|
||||
{ target: '甲', kind: 'url', source: 'https://example.com/a.list' },
|
||||
{ target: '乙', kind: 'url', source: 'https://example.com/a.list' },
|
||||
],
|
||||
{ 甲: 'DIRECT', 乙: 'REJECT' },
|
||||
new Set(),
|
||||
)
|
||||
expect(Object.keys(plan.providers)).toEqual(['a'])
|
||||
expect(plan.rules).toEqual(['RULE-SET,a,DIRECT', 'RULE-SET,a,REJECT'])
|
||||
})
|
||||
|
||||
it('和已有规则组撞名时加序号', () => {
|
||||
const plan = planIniImport(
|
||||
[{ target: '甲', kind: 'url', source: 'https://example.com/ads.list' }],
|
||||
{ 甲: 'REJECT' },
|
||||
new Set(['ads']),
|
||||
)
|
||||
expect(Object.keys(plan.providers)).toEqual(['ads-2'])
|
||||
expect(plan.rules).toEqual(['RULE-SET,ads-2,REJECT'])
|
||||
})
|
||||
|
||||
it('本地路径生成 file 类型,path 就是来源本身', () => {
|
||||
const plan = planIniImport(
|
||||
[{ target: '甲', kind: 'file', source: 'D:\\lists\\ads.list' }],
|
||||
{ 甲: 'REJECT' },
|
||||
new Set(),
|
||||
)
|
||||
expect(plan.providers['ads']).toEqual({
|
||||
type: 'file',
|
||||
behavior: 'classical',
|
||||
path: 'D:\\lists\\ads.list',
|
||||
format: 'text',
|
||||
})
|
||||
})
|
||||
|
||||
it('没映射目标组的行跳过', () => {
|
||||
const plan = planIniImport(entries, { '🎯 全球直连': 'DIRECT' }, new Set())
|
||||
expect(plan.finals).toEqual([])
|
||||
expect(plan.rules).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('清单格式与缓存路径', () => {
|
||||
it('格式按扩展名推断,拿不准回 yaml', () => {
|
||||
expect(inferRuleFormat('https://example.com/a.list?token=1')).toBe('text')
|
||||
expect(inferRuleFormat('https://example.com/a.txt')).toBe('text')
|
||||
expect(inferRuleFormat('https://example.com/a.mrs')).toBe('mrs')
|
||||
expect(inferRuleFormat('https://example.com/a.yaml')).toBe('yaml')
|
||||
expect(inferRuleFormat('https://example.com/no-extension')).toBe('yaml')
|
||||
})
|
||||
|
||||
it('缓存路径的扩展名跟着格式走', () => {
|
||||
expect(ruleProviderPath('ads', 'text')).toBe('./providers/rules/ads.txt')
|
||||
expect(ruleProviderPath('ads', 'mrs')).toBe('./providers/rules/ads.mrs')
|
||||
expect(ruleProviderPath('ads', 'yaml')).toBe('./providers/rules/ads.yaml')
|
||||
expect(ruleProviderPath('🛑 广告', 'yaml')).toBe('./providers/rules/广告.yaml')
|
||||
})
|
||||
|
||||
it('名字只差符号时清单路径撞车,能认出来;自己不算', () => {
|
||||
const data = {
|
||||
'rule-providers': {
|
||||
'🛑 广告': { behavior: 'domain', path: './providers/rules/广告.yaml' },
|
||||
},
|
||||
}
|
||||
const path = ruleProviderPath('广告', 'yaml')
|
||||
expect(ruleProviderPathConflict(data, '广告', path)).toBe('🛑 广告')
|
||||
expect(ruleProviderPathConflict(data, '🛑 广告', path)).toBeNull()
|
||||
expect(ruleProviderPathConflict(data, 'x', ruleProviderPath('x', 'yaml'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('附加规则里的规则组引用', () => {
|
||||
it('sub-rules 各表里的 RULE-SET 引用都认出来,别的表不误报', () => {
|
||||
const data = {
|
||||
'sub-rules': {
|
||||
'patchbay:t1 附加': ['RULE-SET,ads,REJECT', 'MATCH,patchbay:t1'],
|
||||
mine: ['RULE-SET,other,DIRECT'],
|
||||
},
|
||||
}
|
||||
expect(subRuleSetReferences(data, 'ads')).toEqual(['RULE-SET,ads,REJECT'])
|
||||
expect(subRuleSetReferences(data, 'ghost')).toEqual([])
|
||||
expect(subRuleSetReferences({}, 'ads')).toEqual([])
|
||||
})
|
||||
})
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* 分流规则与规则组(rule-providers)的纯逻辑。
|
||||
*
|
||||
* 拆分规则字符串只为显示和编辑常见字段,不为每种规则类型建模——内核认识的类型
|
||||
* 比界面需要的多得多,拆不明白的(逻辑规则、子规则)由界面按原文整行编辑。
|
||||
* 拆分用 vendor 的 splitRuleFields:它认得引号和嵌套括号,`AND`/`OR` 这类载荷里
|
||||
* 的逗号不会被当成字段分隔。
|
||||
*/
|
||||
import { splitRuleFields } from '../vendor/config-editor/index.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
|
||||
/** 一条分流规则拆开的样子。extras 是目标之后的选项段(如 no-resolve),原样保留。 */
|
||||
export interface ParsedRule {
|
||||
type: string
|
||||
/** MATCH 没有载荷,为 null */
|
||||
payload: string | null
|
||||
/** 目标出站。拆不出来(字段不够)时为 null */
|
||||
target: string | null
|
||||
extras: string[]
|
||||
}
|
||||
|
||||
export function parseRule(rule: string): ParsedRule {
|
||||
const fields = splitRuleFields(rule)
|
||||
const type = fields[0] ?? ''
|
||||
const upper = type.toUpperCase()
|
||||
if (upper === 'MATCH') {
|
||||
return { type, payload: null, target: fields[1] ?? null, extras: fields.slice(2) }
|
||||
}
|
||||
// 载荷可含顶层逗号的类型,内核把最后一段读成目标、其余全归载荷、不许带选项
|
||||
// (rules/common/base.go:47-79)。按普通形状拆会把正则截一半当目标。
|
||||
if (RAW_ONLY_TYPES.has(upper) && fields.length >= 3) {
|
||||
return { type, payload: fields.slice(1, -1).join(','), target: fields.at(-1) ?? null, extras: [] }
|
||||
}
|
||||
return { type, payload: fields[1] ?? null, target: fields[2] ?? null, extras: fields.slice(3) }
|
||||
}
|
||||
|
||||
/** 拼回一行规则。空字段不写,所以 MATCH 不会多出一个空载荷段。 */
|
||||
export function buildRule(parsed: ParsedRule): string {
|
||||
return [parsed.type, parsed.payload, parsed.target, ...parsed.extras]
|
||||
.filter((part): part is string => part !== null && part !== '')
|
||||
.join(',')
|
||||
}
|
||||
|
||||
/**
|
||||
* 界面能按「类型,载荷,目标」结构化编辑的规则。
|
||||
*
|
||||
* 这份清单必须和内核 `rules/common/base.go:56` 的 `payloadCanContainComma` 一致:
|
||||
* 那些类型内核按「载荷可含逗号、最后一段是目标、不许带选项」读,按普通形状拆的话,
|
||||
* 正则里的顶层逗号会被当成字段分隔,显示错的目标、编辑后悄悄改变语义。
|
||||
*/
|
||||
const RAW_ONLY_TYPES = new Set([
|
||||
'AND',
|
||||
'OR',
|
||||
'NOT',
|
||||
'SUB-RULE',
|
||||
'DOMAIN-REGEX',
|
||||
'PROCESS-NAME-REGEX',
|
||||
'PROCESS-PATH-REGEX',
|
||||
])
|
||||
|
||||
/** 这条规则是不是只能按原文整行编辑的类型(逻辑规则、载荷可含逗号的正则类)。 */
|
||||
export function isRawOnlyRule(rule: string): boolean {
|
||||
return RAW_ONLY_TYPES.has(parseRule(rule).type.toUpperCase())
|
||||
}
|
||||
|
||||
export function isStructuredEditable(rule: string): boolean {
|
||||
if (isRawOnlyRule(rule)) return false
|
||||
return parseRule(rule).target !== null
|
||||
}
|
||||
|
||||
/** 第一条 MATCH 的下标。它之后的规则永远到不了。没有 MATCH 返回 -1。 */
|
||||
export function firstMatchIndex(rules: string[]): number {
|
||||
return rules.findIndex((rule) => parseRule(rule).type.toUpperCase() === 'MATCH')
|
||||
}
|
||||
|
||||
/** 把新规则插到第一条 MATCH 之前;没有 MATCH 就加在末尾。 */
|
||||
export function insertBeforeMatch(rules: string[], additions: string[]): string[] {
|
||||
const at = firstMatchIndex(rules)
|
||||
const index = at < 0 ? rules.length : at
|
||||
return [...rules.slice(0, index), ...additions, ...rules.slice(index)]
|
||||
}
|
||||
|
||||
/** 上移或下移一条规则。越界时原样返回。 */
|
||||
export function moveRule(rules: string[], index: number, delta: -1 | 1): string[] {
|
||||
const to = index + delta
|
||||
if (index < 0 || index >= rules.length || to < 0 || to >= rules.length) return rules
|
||||
const next = [...rules]
|
||||
next[index] = rules[to]!
|
||||
next[to] = rules[index]!
|
||||
return next
|
||||
}
|
||||
|
||||
/** 一条规则的断引用:目标出站不存在,或者 RULE-SET 引用的规则组不存在。 */
|
||||
export interface RuleIssue {
|
||||
index: number
|
||||
kind: 'target' | 'rule-set'
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 找出引用了不存在的出站或规则组的规则。
|
||||
*
|
||||
* 内核在配置解析时就会拒绝这类引用(config/config.go:1102-1118),坏配置最终会被
|
||||
* 校验重载链拦下并还原;这里的标注是为了让人在保存前就看见、改得快,而 vendor 自检
|
||||
* 对规则目标只给 warning 不拦保存。SUB-RULE 的目标段是子规则集合名而不是出站,
|
||||
* 跳过——那是路由页的段落,这里不认。
|
||||
*/
|
||||
export function findRuleIssues(
|
||||
rules: string[],
|
||||
outboundNames: ReadonlySet<string>,
|
||||
providerNames: ReadonlySet<string>,
|
||||
): RuleIssue[] {
|
||||
const issues: RuleIssue[] = []
|
||||
rules.forEach((rule, index) => {
|
||||
const parsed = parseRule(rule)
|
||||
const type = parsed.type.toUpperCase()
|
||||
if (type === 'RULE-SET' && parsed.payload !== null && !providerNames.has(parsed.payload)) {
|
||||
issues.push({ index, kind: 'rule-set', name: parsed.payload })
|
||||
}
|
||||
if (type !== 'SUB-RULE' && parsed.target !== null && !outboundNames.has(parsed.target)) {
|
||||
issues.push({ index, kind: 'target', name: parsed.target })
|
||||
}
|
||||
})
|
||||
return issues
|
||||
}
|
||||
|
||||
/**
|
||||
* 「内网直连」预设:回环、私网和链路本地地址一律 DIRECT。
|
||||
*
|
||||
* no-resolve 是这类规则的固定尾巴:它们按目标 IP 匹配,让内核先做一次 DNS 解析
|
||||
* 才能匹配是白忙一场,还会拖慢纯 IP 的连接。
|
||||
*/
|
||||
export const LAN_DIRECT_RULES = [
|
||||
'IP-CIDR,127.0.0.0/8,DIRECT,no-resolve',
|
||||
'IP-CIDR,10.0.0.0/8,DIRECT,no-resolve',
|
||||
'IP-CIDR,172.16.0.0/12,DIRECT,no-resolve',
|
||||
'IP-CIDR,192.168.0.0/16,DIRECT,no-resolve',
|
||||
'IP-CIDR,169.254.0.0/16,DIRECT,no-resolve',
|
||||
'IP-CIDR6,::1/128,DIRECT,no-resolve',
|
||||
'IP-CIDR6,fc00::/7,DIRECT,no-resolve',
|
||||
'IP-CIDR6,fe80::/10,DIRECT,no-resolve',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 预设里还没出现在规则表里的那几条。
|
||||
*
|
||||
* 按「类型,载荷」判重而不是整行比对:用户手写的 `IP-CIDR,192.168.0.0/16,DIRECT`
|
||||
* 没有 no-resolve 尾巴,语义上已经管住了这个网段,再塞一条带尾巴的是重复。
|
||||
*/
|
||||
export function missingLanRules(rules: string[]): string[] {
|
||||
const covered = new Set(
|
||||
rules.map((rule) => {
|
||||
const parsed = parseRule(rule)
|
||||
return `${parsed.type.toUpperCase()},${parsed.payload ?? ''}`
|
||||
}),
|
||||
)
|
||||
return LAN_DIRECT_RULES.filter((preset) => {
|
||||
const parsed = parseRule(preset)
|
||||
return !covered.has(`${parsed.type},${parsed.payload ?? ''}`)
|
||||
})
|
||||
}
|
||||
|
||||
/** rules: 里引用了这个规则组的规则下标。删除规则组前拿它拦人。 */
|
||||
export function ruleSetReferences(rules: string[], providerName: string): number[] {
|
||||
const result: number[] = []
|
||||
rules.forEach((rule, index) => {
|
||||
const parsed = parseRule(rule)
|
||||
if (parsed.type.toUpperCase() === 'RULE-SET' && parsed.payload === providerName) {
|
||||
result.push(index)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* sub-rules 各表(含每路入站的附加规则)里引用这个规则组的规则原文。
|
||||
* 删除规则组前和顶层的一起查——漏了的话删除会被内核重载拒掉,用户看到的
|
||||
* 是一段原始解析错误而不是「谁在引用它」。
|
||||
*/
|
||||
export function subRuleSetReferences(data: ConfigObject, providerName: string): string[] {
|
||||
const tables = data['sub-rules']
|
||||
if (typeof tables !== 'object' || tables === null || Array.isArray(tables)) return []
|
||||
const hits: string[] = []
|
||||
for (const rules of Object.values(tables)) {
|
||||
if (!Array.isArray(rules)) continue
|
||||
for (const rule of rules) {
|
||||
if (typeof rule !== 'string') continue
|
||||
const parsed = parseRule(rule)
|
||||
if (parsed.type.toUpperCase() === 'RULE-SET' && parsed.payload === providerName) {
|
||||
hits.push(rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
/** 配置里的 rule-providers 段。不是映射就当没有。 */
|
||||
export function ruleProvidersOf(data: ConfigObject): Record<string, ConfigObject> {
|
||||
const value = data['rule-providers']
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}
|
||||
return value as Record<string, ConfigObject>
|
||||
}
|
||||
|
||||
/** 直接改传入的对象——调用方先 clone。providers 空了就把整个键去掉,不留空映射。 */
|
||||
export function setRuleProviders(data: ConfigObject, providers: Record<string, ConfigObject>): void {
|
||||
if (Object.keys(providers).length === 0) delete data['rule-providers']
|
||||
else data['rule-providers'] = providers
|
||||
}
|
||||
|
||||
/** 配置里的分流规则表。不是数组就当没有;非字符串的条目自检会报,这里不呈现。 */
|
||||
export function rulesOf(data: ConfigObject): string[] {
|
||||
if (!Array.isArray(data.rules)) return []
|
||||
return data.rules.filter((rule): rule is string => typeof rule === 'string')
|
||||
}
|
||||
|
||||
/** 同上。规则表空了就删掉 rules 键,不留空数组。 */
|
||||
export function setRules(data: ConfigObject, rules: string[]): void {
|
||||
if (rules.length === 0) delete data.rules
|
||||
else data.rules = rules
|
||||
}
|
||||
|
||||
/** 给文件名和规则组名去重用。 */
|
||||
function safeName(text: string): string {
|
||||
return text.replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
/** FNV-1a。只用来给名字去重,不是安全用途。 */
|
||||
function shortHash(text: string): string {
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
h ^= text.charCodeAt(i)
|
||||
h = Math.imul(h, 0x01000193) >>> 0
|
||||
}
|
||||
return h.toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个 http 规则组的缓存文件落在哪。
|
||||
*
|
||||
* 不写 path 时内核按 url 哈希自己生成一个(rules/provider/parse.go:51),那个路径
|
||||
* 这里算不出来,所以补一个确定的。扩展名跟着格式走,翻目录时看得出里面是什么。
|
||||
*/
|
||||
export function ruleProviderPath(name: string, format: string): string {
|
||||
const ext = format === 'mrs' ? 'mrs' : format === 'text' ? 'txt' : 'yaml'
|
||||
return `./providers/rules/${safeName(name) || shortHash(name)}.${ext}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 新规则组的清单路径是否和现有规则组的撞车。
|
||||
*
|
||||
* 名字查重挡不住这种:路径由名字净化而来,「广告 A」和「广告/A」是两个名字、
|
||||
* 同一个清单文件,互相覆盖。返回撞上的那个规则组名,没撞返回 null。
|
||||
*/
|
||||
export function ruleProviderPathConflict(
|
||||
data: ConfigObject,
|
||||
selfName: string,
|
||||
path: string,
|
||||
): string | null {
|
||||
for (const [name, decl] of Object.entries(ruleProvidersOf(data))) {
|
||||
if (name === selfName) continue
|
||||
// 没写 path 的声明由内核按 url 哈希生成路径,和这里的净化路径撞不上
|
||||
if (decl.path === path) return name
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 清单格式按来源的扩展名推断。拿不准时回到 yaml——那是内核没写 format 时的默认。 */
|
||||
export function inferRuleFormat(source: string): 'yaml' | 'text' | 'mrs' {
|
||||
const clean = (source.split(/[?#]/)[0] ?? '').toLowerCase()
|
||||
if (clean.endsWith('.mrs')) return 'mrs'
|
||||
if (clean.endsWith('.list') || clean.endsWith('.txt')) return 'text'
|
||||
return 'yaml'
|
||||
}
|
||||
|
||||
/** ini 里一行 ruleset= 解析出来的东西。 */
|
||||
export interface IniRuleset {
|
||||
/** ini 里的目标组名(「🎯 全球直连」这类),导入时由用户映射到实际出站 */
|
||||
target: string
|
||||
kind: 'url' | 'file' | 'geoip' | 'geosite' | 'final'
|
||||
/** kind 为 url/file 时是来源,为 geoip/geosite 时是类别名,final 时为空 */
|
||||
source: string
|
||||
/** geoip/geosite 类别后面的附加参数(no-resolve 这类)。生成规则时放在目标之后 */
|
||||
flags?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 subconverter ini 里的 ruleset= 行。
|
||||
*
|
||||
* 以 ; 和 # 开头的是 ini 的注释,跳过。custom_proxy_group= 是 subconverter 自己的
|
||||
* 建组指令,和本项目的专属组体系冲突,不在解析范围内。
|
||||
* 来源以 [] 开头的是内联规则而不是清单:[]GEOIP,CN 这类直接生成规则,不建规则组;
|
||||
* []FINAL(以及 []MATCH)是兜底规则。
|
||||
*/
|
||||
export function parseIniRulesets(text: string): IniRuleset[] {
|
||||
const result: IniRuleset[] = []
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed === '' || trimmed.startsWith(';') || trimmed.startsWith('#')) continue
|
||||
const match = /^ruleset\s*=\s*(.+)$/i.exec(trimmed)
|
||||
if (!match) continue
|
||||
const body = match[1]!
|
||||
const comma = body.indexOf(',')
|
||||
if (comma < 0) continue
|
||||
const target = body.slice(0, comma).trim()
|
||||
const source = body.slice(comma + 1).trim()
|
||||
if (target === '' || source === '') continue
|
||||
|
||||
if (source.startsWith('[]')) {
|
||||
const inline = source.slice(2).trim()
|
||||
const geo = /^(GEOIP|GEOSITE)\s*,\s*(.+)$/i.exec(inline)
|
||||
if (geo) {
|
||||
// []GEOIP,CN,no-resolve:第二段才是类别,再往后是 no-resolve 这类附加参数。
|
||||
// 附加参数在 mihomo 规则里的位置是目标之后,混进类别名会生成字段错位的规则
|
||||
const fields = geo[2]!
|
||||
.split(',')
|
||||
.map((field) => field.trim())
|
||||
.filter(Boolean)
|
||||
const [category, ...flags] = fields
|
||||
if (!category) continue
|
||||
result.push({
|
||||
target,
|
||||
kind: geo[1]!.toUpperCase() === 'GEOIP' ? 'geoip' : 'geosite',
|
||||
source: category,
|
||||
...(flags.length > 0 ? { flags } : {}),
|
||||
})
|
||||
} else if (inline.toUpperCase() === 'FINAL' || inline.toUpperCase() === 'MATCH') {
|
||||
result.push({ target, kind: 'final', source: '' })
|
||||
}
|
||||
// 其它 [] 写法([]REJECT 这类)认不出来,跳过比生成一条错规则好
|
||||
continue
|
||||
}
|
||||
result.push({ target, kind: /^https?:\/\//i.test(source) ? 'url' : 'file', source })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** ini 导入的产物:一批规则组声明,加一批新规则。 */
|
||||
export interface IniImportPlan {
|
||||
providers: Record<string, ConfigObject>
|
||||
/** 普通规则,插在第一条 MATCH 之前 */
|
||||
rules: string[]
|
||||
/** ini 里 []FINAL 生成的兜底规则,加在整张表末尾 */
|
||||
finals: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 ini 的 ruleset 行变成规则组声明和规则。
|
||||
*
|
||||
* 每个来源生成一个规则组,名字取来源的文件名(去扩展名),和已有的、以及本次
|
||||
* 已生成的撞名时加序号。ACL4SSR 的清单是 classical 行为的 text 格式,行为按
|
||||
* classical 声明,格式按扩展名推断。
|
||||
* 目标组名由 mapping 逐个映射到实际出站,没映射的行跳过。
|
||||
*/
|
||||
export function planIniImport(
|
||||
entries: IniRuleset[],
|
||||
mapping: Readonly<Record<string, string>>,
|
||||
existingProviders: ReadonlySet<string>,
|
||||
): IniImportPlan {
|
||||
const providers: Record<string, ConfigObject> = {}
|
||||
const rules: string[] = []
|
||||
const finals: string[] = []
|
||||
const taken = new Set(existingProviders)
|
||||
|
||||
/** 同一行 ruleset 重复出现时共用一个名字,不重复声明。 */
|
||||
const nameBySource = new Map<string, string>()
|
||||
const nameFor = (source: string): string => {
|
||||
const known = nameBySource.get(source)
|
||||
if (known) return known
|
||||
const base = (source.split(/[?#]/)[0] ?? '').split(/[\\/]/).pop() ?? ''
|
||||
const stem = safeName(base.replace(/\.(list|txt|yaml|yml|mrs)$/i, '')) || 'rules'
|
||||
let name = stem
|
||||
for (let i = 2; taken.has(name); i += 1) name = `${stem}-${String(i)}`
|
||||
taken.add(name)
|
||||
nameBySource.set(source, name)
|
||||
return name
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const target = mapping[entry.target]
|
||||
if (!target) continue
|
||||
|
||||
if (entry.kind === 'geoip' || entry.kind === 'geosite') {
|
||||
const type = entry.kind === 'geoip' ? 'GEOIP' : 'GEOSITE'
|
||||
rules.push([type, entry.source, target, ...(entry.flags ?? [])].join(','))
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'final') {
|
||||
finals.push(`MATCH,${target}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const format = inferRuleFormat(entry.source)
|
||||
const name = nameFor(`${entry.kind}:${entry.source}`)
|
||||
if (!providers[name]) {
|
||||
providers[name] =
|
||||
entry.kind === 'url'
|
||||
? {
|
||||
type: 'http',
|
||||
behavior: 'classical',
|
||||
url: entry.source,
|
||||
path: ruleProviderPath(name, format),
|
||||
format,
|
||||
interval: 86400,
|
||||
}
|
||||
: { type: 'file', behavior: 'classical', path: entry.source, format }
|
||||
}
|
||||
rules.push(`RULE-SET,${name},${target}`)
|
||||
}
|
||||
return { providers, rules, finals }
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
bucketRates,
|
||||
lastRate,
|
||||
pushWindow,
|
||||
routeDeltas,
|
||||
windowBytes,
|
||||
type ConnTotals,
|
||||
type RouteSample,
|
||||
} from './telemetry.ts'
|
||||
import type { ConnectionItem } from './connections.ts'
|
||||
|
||||
function conn(id: string, inbound: string, upload: number, download: number): ConnectionItem {
|
||||
return {
|
||||
id,
|
||||
upload,
|
||||
download,
|
||||
start: '2026-08-11T00:00:00+08:00',
|
||||
chains: ['DIRECT'],
|
||||
rule: 'Match',
|
||||
rulePayload: '',
|
||||
metadata: {
|
||||
host: 'example.com',
|
||||
destinationIP: '1.2.3.4',
|
||||
destinationPort: '443',
|
||||
inboundName: inbound,
|
||||
inboundPort: '7890',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('pushWindow', () => {
|
||||
test('把窗口外的老点挤掉,保留窗口内的', () => {
|
||||
const list = [{ t: 0 }, { t: 5_000 }, { t: 9_000 }]
|
||||
const next = pushWindow(list, { t: 12_000 }, 5_000)
|
||||
expect(next.map((p) => p.t)).toEqual([9_000, 12_000])
|
||||
})
|
||||
|
||||
test('全部过期时只剩新点', () => {
|
||||
const next = pushWindow([{ t: 0 }], { t: 60_000 }, 5_000)
|
||||
expect(next.map((p) => p.t)).toEqual([60_000])
|
||||
})
|
||||
})
|
||||
|
||||
describe('routeDeltas', () => {
|
||||
test('已有连接算差值,并按入站聚合', () => {
|
||||
const prev: ConnTotals = new Map([
|
||||
['a', { up: 100, down: 200 }],
|
||||
['b', { up: 10, down: 20 }],
|
||||
])
|
||||
const { deltas } = routeDeltas(prev, [
|
||||
conn('a', 'lan-a', 150, 260),
|
||||
conn('b', 'lan-a', 30, 21),
|
||||
])
|
||||
expect(deltas['lan-a']).toEqual({ up: 70, down: 61 })
|
||||
})
|
||||
|
||||
test('新连接把累计全部记入本次间隔', () => {
|
||||
const { deltas } = routeDeltas(new Map(), [conn('a', 'lan-a', 500, 700)])
|
||||
expect(deltas['lan-a']).toEqual({ up: 500, down: 700 })
|
||||
})
|
||||
|
||||
test('累计变小(内核重启)按全新连接处理,不出负数', () => {
|
||||
const prev: ConnTotals = new Map([['a', { up: 900, down: 900 }]])
|
||||
const { deltas } = routeDeltas(prev, [conn('a', 'lan-a', 50, 60)])
|
||||
expect(deltas['lan-a']).toEqual({ up: 50, down: 60 })
|
||||
})
|
||||
|
||||
test('消失的连接不再出现在 next 里', () => {
|
||||
const prev: ConnTotals = new Map([['gone', { up: 1, down: 1 }]])
|
||||
const { next } = routeDeltas(prev, [conn('a', 'lan-a', 5, 5)])
|
||||
expect(next.has('gone')).toBe(false)
|
||||
expect(next.get('a')).toEqual({ up: 5, down: 5 })
|
||||
})
|
||||
|
||||
test('没有名字的入站退回端口做键', () => {
|
||||
const item = conn('a', '', 5, 5)
|
||||
const { deltas } = routeDeltas(new Map(), [item])
|
||||
expect(Object.keys(deltas)).toEqual(['7890'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('bucketRates', () => {
|
||||
test('样本按桶平均,桶的时间戳在桶中央', () => {
|
||||
const samples: RouteSample[] = [
|
||||
{ t: 1_000, ms: 1_000, up: 100, down: 0 },
|
||||
{ t: 2_000, ms: 1_000, up: 300, down: 0 },
|
||||
{ t: 6_000, ms: 1_000, up: 50, down: 50 },
|
||||
]
|
||||
// 0-10s 分两桶:前桶 400 字节 / 2 秒 = 200 B/s,后桶 100 字节 / 1 秒 = 100 B/s
|
||||
expect(bucketRates(samples, 0, 10_000, 2)).toEqual([
|
||||
[2_500, 200],
|
||||
[7_500, 100],
|
||||
])
|
||||
})
|
||||
|
||||
test('开头没数据的桶被裁掉,中间的留成 null', () => {
|
||||
const samples: RouteSample[] = [
|
||||
{ t: 3_000, ms: 1_000, up: 100, down: 0 },
|
||||
{ t: 9_000, ms: 1_000, up: 100, down: 0 },
|
||||
]
|
||||
const points = bucketRates(samples, 0, 10_000, 5)
|
||||
expect(points).toHaveLength(4)
|
||||
expect(points[0]).toEqual([3_000, 100])
|
||||
expect(points[1]![1]).toBeNull()
|
||||
})
|
||||
|
||||
test('没有任何样本返回空数组', () => {
|
||||
expect(bucketRates([], 0, 10_000, 5)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('windowBytes / lastRate', () => {
|
||||
const samples: RouteSample[] = [
|
||||
{ t: 0, ms: 3_000, up: 100, down: 100 },
|
||||
{ t: 3_000, ms: 3_000, up: 300, down: 300 },
|
||||
{ t: 6_000, ms: 2_000, up: 400, down: 600 },
|
||||
]
|
||||
|
||||
test('windowBytes 只算窗口内的样本', () => {
|
||||
expect(windowBytes(samples, 3_000)).toBe(300 + 300 + 400 + 600)
|
||||
})
|
||||
|
||||
test('lastRate 按实际间隔换算成每秒', () => {
|
||||
expect(lastRate(samples)).toEqual({ up: 200, down: 300 })
|
||||
})
|
||||
|
||||
test('lastRate 没有样本时是 null', () => {
|
||||
expect(lastRate([])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 仪表盘遥测的纯逻辑:滑动窗口和按入站聚合的吞吐差值。
|
||||
*
|
||||
* 采集本身(开流、轮询)在 web/telemetry.ts 的单例里做;这里只回答
|
||||
* 「两次连接快照之间每一路走了多少字节」这类纯问题,让 bun test 够得着。
|
||||
*/
|
||||
import type { ConnectionItem } from './connections.ts'
|
||||
|
||||
/** 流量大图保留多久的历史。切走页面数据还在,是这个窗口在兜底。 */
|
||||
export const TRAFFIC_WINDOW_MS = 30 * 60_000
|
||||
|
||||
/** 每路吞吐走势保留多久。活跃判定也用同一个窗口。 */
|
||||
export const ROUTE_WINDOW_MS = 5 * 60_000
|
||||
|
||||
/** 连接快照的轮询间隔。 */
|
||||
export const ROUTE_POLL_MS = 3_000
|
||||
|
||||
/**
|
||||
* 「活跃」的门槛:最近 5 分钟走过的字节数。
|
||||
* 取 256 KB 是为了滤掉心跳、DNS 这类背景流量——它们在 5 分钟里通常只有几十 KB,
|
||||
* 而任何真实使用(网页、更新检查)轻松超过这个数。
|
||||
*/
|
||||
export const ACTIVE_BYTES = 256 * 1024
|
||||
|
||||
/** 一个采样点。up/down 是这个采样间隔里走过的字节数(不是速率),ms 是实际间隔。 */
|
||||
export interface RouteSample {
|
||||
t: number
|
||||
ms: number
|
||||
up: number
|
||||
down: number
|
||||
}
|
||||
|
||||
/** 流量曲线的一个点。up/down 是内核报的每秒字节数。 */
|
||||
export interface TrafficPoint {
|
||||
t: number
|
||||
up: number
|
||||
down: number
|
||||
}
|
||||
|
||||
/** 追加一个点并把窗口外的老点挤掉。窗口按时间算,不按条数——采样间隔不保证均匀。 */
|
||||
export function pushWindow<T extends { t: number }>(list: T[], point: T, windowMs: number): T[] {
|
||||
const cutoff = point.t - windowMs
|
||||
const start = list.findIndex((p) => p.t >= cutoff)
|
||||
return [...(start === -1 ? [] : list.slice(start)), point]
|
||||
}
|
||||
|
||||
/** 上次快照里每条连接的累计字节,按连接 id 记。 */
|
||||
export type ConnTotals = Map<string, { up: number; down: number }>
|
||||
|
||||
/**
|
||||
* 两次连接快照之间,每一路入站走了多少字节。
|
||||
*
|
||||
* 按连接逐条求差再按入站聚合,而不是先按入站求和再求差:连接关闭后会从快照里
|
||||
* 消失,直接对总和求差会算出负数。代价是连接关闭前最后一段流量丢失——对走势图
|
||||
* 来说可以接受。新出现的连接把它的累计全部记入本次间隔(它就是这段时间里产生的)。
|
||||
* 同一条 id 的累计变小说明内核重启过计数,按全新连接处理。
|
||||
*/
|
||||
export function routeDeltas(
|
||||
prev: ConnTotals,
|
||||
conns: ConnectionItem[],
|
||||
): { deltas: Record<string, { up: number; down: number }>; next: ConnTotals } {
|
||||
const deltas: Record<string, { up: number; down: number }> = {}
|
||||
const next: ConnTotals = new Map()
|
||||
for (const conn of conns) {
|
||||
const key = conn.metadata.inboundName || conn.metadata.inboundPort
|
||||
const last = prev.get(conn.id)
|
||||
const fresh = !last || last.up > conn.upload || last.down > conn.download
|
||||
const up = fresh ? conn.upload : conn.upload - last.up
|
||||
const down = fresh ? conn.download : conn.download - last.down
|
||||
const entry = (deltas[key] ??= { up: 0, down: 0 })
|
||||
entry.up += up
|
||||
entry.down += down
|
||||
next.set(conn.id, { up: conn.upload, down: conn.download })
|
||||
}
|
||||
return { deltas, next }
|
||||
}
|
||||
|
||||
/** 窗口内一共走了多少字节(上下行合计)。活跃判定用。 */
|
||||
export function windowBytes(samples: RouteSample[], since: number): number {
|
||||
let total = 0
|
||||
for (const s of samples) {
|
||||
if (s.t >= since) total += s.up + s.down
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** 最近一个采样的速率(字节每秒)。没有采样时是 null——「没数据」和 0 不是一回事。 */
|
||||
export function lastRate(samples: RouteSample[]): { up: number; down: number } | null {
|
||||
const last = samples.at(-1)
|
||||
if (!last || last.ms <= 0) return null
|
||||
return { up: (last.up / last.ms) * 1000, down: (last.down / last.ms) * 1000 }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把窗口内的样本合并成固定数量的桶,每桶是平均速率(字节每秒)。
|
||||
*
|
||||
* 走势线要的是形状,不是每一次采样:3 秒一个点、5 分钟窗口是一百个点,
|
||||
* 全画在一条几十像素宽的线上只剩噪声。没有采样的桶是 null(「没在采集」,
|
||||
* 不是「速率为 0」——零样本是采集层补出来的真零),开头的 null 直接裁掉,
|
||||
* 采集是从中途开始的,不该画出一段假的静止期。
|
||||
*/
|
||||
export function bucketRates(
|
||||
samples: RouteSample[],
|
||||
start: number,
|
||||
end: number,
|
||||
buckets: number,
|
||||
): Array<[number, number | null]> {
|
||||
if (end <= start || buckets <= 0) return []
|
||||
const width = (end - start) / buckets
|
||||
const sums = Array.from({ length: buckets }, () => ({ bytes: 0, ms: 0 }))
|
||||
for (const s of samples) {
|
||||
if (s.t < start || s.t > end) continue
|
||||
const i = Math.min(buckets - 1, Math.floor((s.t - start) / width))
|
||||
sums[i]!.bytes += s.up + s.down
|
||||
sums[i]!.ms += s.ms
|
||||
}
|
||||
const points = sums.map(
|
||||
(b, i): [number, number | null] => [
|
||||
Math.round(start + (i + 0.5) * width),
|
||||
b.ms > 0 ? (b.bytes / b.ms) * 1000 : null,
|
||||
],
|
||||
)
|
||||
const first = points.findIndex(([, v]) => v !== null)
|
||||
return first === -1 ? [] : points.slice(first)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { openDocument, replaceDocumentData } from '../vendor/config-editor/index.ts'
|
||||
import { openDocument } from '../vendor/config-editor/index.ts'
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { rewriteYaml } from '../server/config-store.ts'
|
||||
import { restoreInlineCommentAlignment } from './yaml-cosmetics.ts'
|
||||
|
||||
const fixture = readFileSync(
|
||||
@@ -12,12 +13,14 @@ const fixture = readFileSync(
|
||||
|
||||
const clone = (v: unknown) => JSON.parse(JSON.stringify(v)) as ConfigObject
|
||||
|
||||
/** 走一遍真实的写回链路:解析 → 改 → 写回 → 修复格式。 */
|
||||
/**
|
||||
* 走**生产用的那一条**写回路径,而不是在测试里重拼一遍。
|
||||
* 自己拼的话,落盘链路里漏掉其中一步这些测试照样会绿——那就白写了。
|
||||
*/
|
||||
function roundtrip(source: string, mutate: (draft: ConfigObject) => void): string {
|
||||
const doc = openDocument(source)
|
||||
const draft = clone(doc.data)
|
||||
const draft = clone(openDocument(source).data)
|
||||
mutate(draft)
|
||||
return restoreInlineCommentAlignment(source, replaceDocumentData(doc, draft).yaml)
|
||||
return rewriteYaml(source, draft)
|
||||
}
|
||||
|
||||
describe('写回一份带注释的配置', () => {
|
||||
|
||||
+1
-3
@@ -21,9 +21,7 @@
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./web/*"],
|
||||
"@shared/*": ["./shared/*"],
|
||||
"@config-editor": ["./vendor/config-editor/index.ts"]
|
||||
"@shared/*": ["./shared/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
Vendored
+1
@@ -12,6 +12,7 @@
|
||||
|---|---|---|
|
||||
| `index.test.ts` | 测试框架的导入从 `vitest` 换成 `bun:test` | 本项目用 Bun 自带的测试运行器,不引入 vitest。两者的 `describe` / `it` / `expect` 接口一致,用例本身一行没动 |
|
||||
| `index.ts` | `stringifyTouched` 结尾的 `String(doc)` 换成 `doc.toString({ flowCollectionPadding: false, lineWidth: 0 })` | 上游用默认输出参数,会把 `[a, b]` 改写成 `[ a, b ]`,并且在超过 80 列时折行——目标机器的订阅地址就够长。这两样都会让没被改动的段落出现在 diff 里 |
|
||||
| `index.ts` | `validateDocument` 末尾新增:listener 的 `rule:` 指向不存在的 sub-rules 表时报 `missing-reference` | 内核对这种引用静默退回全局规则(tunnel.go getRules 的 else 分支),`mihomo -t` 和重载都不会拦,只有这层自检能拦住 |
|
||||
|
||||
## 配套的本项目代码
|
||||
|
||||
|
||||
Vendored
+19
@@ -1056,6 +1056,25 @@ export function validateDocument(data: ConfigObject): ConfigDiagnostic[] {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Patchbay local addition (see ORIGIN.md): a listener whose `rule:` names a
|
||||
// missing sub-rules table silently falls back to the global rules (mihomo
|
||||
// tunnel.go getRules), so neither `mihomo -t` nor a reload will ever flag it.
|
||||
if (Array.isArray(data.listeners)) {
|
||||
data.listeners.forEach((entry, index) => {
|
||||
if (!isObject(entry)) return
|
||||
const rule = entry.rule
|
||||
if (typeof rule !== 'string' || !rule) return
|
||||
if (!isObject(subRules) || !(rule in subRules)) {
|
||||
diagnostics.push({
|
||||
path: ['listeners', index, 'rule'],
|
||||
code: 'missing-reference',
|
||||
message: `Unknown sub-rule: ${rule}`,
|
||||
severity: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -18,14 +18,15 @@ export default defineConfig({
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': fileURLToPath(new URL('./web', import.meta.url)),
|
||||
'@shared': fileURLToPath(new URL('./shared', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// 必须是正则而不是字符串 '/api':字符串规则按前缀匹配,会把 web/api.ts
|
||||
// 这个模块的请求(/api.ts)也转发给后端,于是开发模式下整个页面加载不出来。
|
||||
'^/api/': {
|
||||
target: 'http://127.0.0.1:7580',
|
||||
changeOrigin: false,
|
||||
},
|
||||
@@ -34,5 +35,14 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: '../web-dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// ECharts 单独一个 chunk。应用一进来就是仪表盘,懒加载省不了首屏,
|
||||
// 拆开图的是缓存粒度:业务代码天天变,这 400KB 的库不跟着换 hash 重下
|
||||
manualChunks: {
|
||||
echarts: ['echarts/core', 'echarts/charts', 'echarts/components', 'echarts/renderers'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+487
-84
@@ -1,76 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ApiError, api, getToken, setToken } from './api'
|
||||
import type { ConfigSnapshot, ProxyInfo } from './api'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ApiError, api, clearToken, getToken, setToken } from './api'
|
||||
import type { ConfigSnapshot } from './api'
|
||||
import { NAV } from './nav'
|
||||
import { density, toggleDensity } from './prefs'
|
||||
import { kernel, refreshKernel, reloadRuntime } from './runtime'
|
||||
import { startTelemetry } from './telemetry'
|
||||
import { startLogStream } from './log-store'
|
||||
import ConfigPage from './views/ConfigPage.vue'
|
||||
import ConnectionsPage from './views/ConnectionsPage.vue'
|
||||
import DashboardPage from './views/DashboardPage.vue'
|
||||
import RequestsPage from './views/RequestsPage.vue'
|
||||
import RouterPage from './views/RouterPage.vue'
|
||||
import RulesPage from './views/RulesPage.vue'
|
||||
import SettingsPage from './views/SettingsPage.vue'
|
||||
import TokenGate from './views/TokenGate.vue'
|
||||
import ToastHost from './components/ToastHost.vue'
|
||||
import HistoryDialog from './components/HistoryDialog.vue'
|
||||
import ConfirmDialog from './components/ConfirmDialog.vue'
|
||||
import { toast, toastError } from './toast'
|
||||
import IconFold from '~icons/ri/sidebar-fold-line'
|
||||
import IconUnfold from '~icons/ri/sidebar-unfold-line'
|
||||
import IconLogout from '~icons/ri/logout-box-r-line'
|
||||
import IconUndo from '~icons/ri/arrow-go-back-line'
|
||||
import IconRedo from '~icons/ri/arrow-go-forward-line'
|
||||
import IconHistory from '~icons/ri/history-line'
|
||||
import IconReload from '~icons/ri/refresh-line'
|
||||
import IconRestart from '~icons/ri/restart-line'
|
||||
|
||||
const snapshot = ref<ConfigSnapshot | null>(null)
|
||||
const kernel = ref<{ online: boolean; version?: string }>({ online: false })
|
||||
const providerLines = ref<string[]>([])
|
||||
const runtime = ref<Record<string, ProxyInfo>>({})
|
||||
const needsToken = ref(!getToken())
|
||||
const loadError = ref('')
|
||||
const loading = ref(false)
|
||||
const active = ref('router')
|
||||
const booted = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const active = ref('dashboard')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
/**
|
||||
* 仪表盘的路由行点过去时要选中的那一路。放外壳是因为跳转跨页面:
|
||||
* 仪表盘发起、路由页消费,消费完清掉,下次再点同一行还能再跳。
|
||||
*/
|
||||
const routerFocus = ref<string | null>(null)
|
||||
|
||||
function openInbound(id: string) {
|
||||
routerFocus.value = id
|
||||
active.value = 'router'
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新读一遍状态。
|
||||
*
|
||||
* 关键在于**不能把页面拆掉重建**:之前每次刷新都会先切到「读取中」,于是路由页被卸载、
|
||||
* 选中的那一路入站丢失,改完一个设置还要重新找回原来的位置。所以只有首次加载显示占位,
|
||||
* 之后都是原地更新。
|
||||
*/
|
||||
async function load(): Promise<'ok' | 'unauthorized' | 'error'> {
|
||||
refreshing.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const [config, status] = await Promise.all([api.readConfig(), api.kernel()])
|
||||
snapshot.value = config
|
||||
kernel.value = status
|
||||
// 先刷运行时再换配置数据。反过来的话,保存后界面已经按新配置渲染,
|
||||
// 而新建的组在运行时里还查不到,成员卡片会先闪空一下再出现。
|
||||
await reloadRuntime()
|
||||
snapshot.value = await api.readConfig()
|
||||
needsToken.value = false
|
||||
// 订阅里有哪些线路只有运行中的内核知道,配置文件里只有一个订阅地址。
|
||||
// 拿不到不算错误——界面照常能编辑,只是订阅线路那一档不可选。
|
||||
providerLines.value = status.online ? await readProviderLines() : []
|
||||
runtime.value = status.online ? await readRuntime() : {}
|
||||
// 撤回/重做按钮的可用性跟着每次重读走
|
||||
void refreshHistory()
|
||||
return 'ok'
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.unauthorized) {
|
||||
clearToken()
|
||||
needsToken.value = true
|
||||
} else {
|
||||
loadError.value = error instanceof Error ? error.message : String(error)
|
||||
return 'unauthorized'
|
||||
}
|
||||
loadError.value = error instanceof Error ? error.message : String(error)
|
||||
return 'error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
booted.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** 只取真正的订阅。内核会给每个用了 proxies 的策略组造一个内部 provider,那些不是订阅。 */
|
||||
async function readProviderLines(): Promise<string[]> {
|
||||
try {
|
||||
const { providers } = await api.providers()
|
||||
return Object.values(providers)
|
||||
.filter((p) => p.vehicleType !== 'Compatible')
|
||||
.flatMap((p) => p.proxies.map((line) => line.name))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。 */
|
||||
async function readRuntime(): Promise<Record<string, ProxyInfo>> {
|
||||
try {
|
||||
return (await api.proxies()).proxies
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function submitToken(value: string) {
|
||||
/**
|
||||
* 提交登录页填的令牌。
|
||||
*
|
||||
* 401 必须当场说一声:登录页在提交前后都是它自己,组件不重建、输入框也不清空,
|
||||
* 不提示的话点了「进入」界面一个字都不变,看着像没反应。
|
||||
* 只在这里提示,不在 load() 里——存着的令牌失效时弹回登录页,页面本身就是说明。
|
||||
*/
|
||||
async function submitToken(value: string) {
|
||||
setToken(value)
|
||||
void load()
|
||||
// 令牌在哪儿取,登录页上就写着,不必在提示里再说一遍
|
||||
if ((await load()) === 'unauthorized') toast('令牌不正确', { kind: 'error' })
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken()
|
||||
location.reload()
|
||||
}
|
||||
|
||||
/* ── 侧栏左下角的配置动作 ─────────────────────── */
|
||||
|
||||
/** 撤回栈(备份链)和重做栈的深度,决定按钮亮不亮。每次 load() 时刷新。 */
|
||||
const history = ref<{ backups: string[]; redo: number }>({ backups: [], redo: 0 })
|
||||
|
||||
async function refreshHistory() {
|
||||
history.value = await api.listBackups().catch(() => ({ backups: [], redo: 0 }))
|
||||
}
|
||||
|
||||
const busyAct = ref(false)
|
||||
const historyOpen = ref(false)
|
||||
const askRestart = ref(false)
|
||||
|
||||
/**
|
||||
* 撤回/重做各自的冷却:窗内的连点算一次。手抖不该多退一步;顺带避开
|
||||
* 重做的备份名同秒覆盖(备份名精确到秒,窗比一秒宽就撞不上)。
|
||||
* 两个方向分开冷却,撤回错了马上重做这条路不受影响。
|
||||
*/
|
||||
const HISTORY_COOLDOWN_MS = 2_500
|
||||
const cooling = ref({ undo: false, redo: false })
|
||||
|
||||
function coolDown(kind: 'undo' | 'redo') {
|
||||
cooling.value = { ...cooling.value, [kind]: true }
|
||||
window.setTimeout(() => {
|
||||
cooling.value = { ...cooling.value, [kind]: false }
|
||||
}, HISTORY_COOLDOWN_MS)
|
||||
}
|
||||
|
||||
async function undo() {
|
||||
if (busyAct.value || cooling.value.undo) return
|
||||
coolDown('undo')
|
||||
busyAct.value = true
|
||||
try {
|
||||
const result = await api.undoConfig()
|
||||
toast(result.reloaded ? '已撤回上一次改动' : `已撤回上一次改动(${result.reloadMessage})`)
|
||||
await load()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busyAct.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function redo() {
|
||||
if (busyAct.value || cooling.value.redo) return
|
||||
coolDown('redo')
|
||||
busyAct.value = true
|
||||
try {
|
||||
const result = await api.redoConfig()
|
||||
toast(result.reloaded ? '已重做' : `已重做(${result.reloadMessage})`)
|
||||
await load()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busyAct.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 让内核重读磁盘上的配置文件,界面跟着重读。给 SSH 手改过配置的场景用。 */
|
||||
async function reloadFromDisk() {
|
||||
if (busyAct.value) return
|
||||
busyAct.value = true
|
||||
try {
|
||||
await api.reloadKernelConfig()
|
||||
toast('内核已重新加载配置文件')
|
||||
await load()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busyAct.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重启后每 2 秒探一次、最多 30 秒;心跳兜底是 10 秒一次,这里只是快一点。 */
|
||||
let restartTimer = 0
|
||||
onBeforeUnmount(() => window.clearInterval(restartTimer))
|
||||
|
||||
async function restartConfirmed() {
|
||||
askRestart.value = false
|
||||
try {
|
||||
await api.restartKernel()
|
||||
kernel.value = { online: false }
|
||||
toast('已发出重启指令,等待内核回来')
|
||||
const started = Date.now()
|
||||
window.clearInterval(restartTimer)
|
||||
restartTimer = window.setInterval(() => {
|
||||
void refreshKernel().then(() => {
|
||||
if (kernel.value.online) {
|
||||
window.clearInterval(restartTimer)
|
||||
void reloadRuntime()
|
||||
toast('内核已恢复在线')
|
||||
} else if (Date.now() - started > 30_000) {
|
||||
window.clearInterval(restartTimer)
|
||||
toast('内核 30 秒内没有回来', { kind: 'error', hint: '去日志页看看它在说什么。' })
|
||||
}
|
||||
})
|
||||
}, 2_000)
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!needsToken.value) void load()
|
||||
// 流量、每路吞吐和日志的历史跨页面留存,所以收集挂在外壳上而不是各页里。
|
||||
// 单例自己跟着 kernel.online 起落,这里只负责点火
|
||||
startTelemetry()
|
||||
startLogStream()
|
||||
})
|
||||
|
||||
/**
|
||||
* 内核状态的心跳。kernel.online 原本只在 load() 里更新,用户停在任何页面期间它都是
|
||||
* 静态快照:内核重启一次,请求页和仪表盘会锁死在「内核未连接」,各页等它回来的 watch
|
||||
* 永远不会触发。10 秒探一次(/api/kernel 是个 5 秒超时的轻量探测),恢复在线的那一次
|
||||
* 顺带把运行时数据拉新,路由页的组和线路也跟着回来。
|
||||
*/
|
||||
let kernelTimer = 0
|
||||
onMounted(() => {
|
||||
kernelTimer = window.setInterval(() => {
|
||||
if (needsToken.value || !snapshot.value) return
|
||||
const wasOnline = kernel.value.online
|
||||
void refreshKernel().then(() => {
|
||||
if (!wasOnline && kernel.value.online) void reloadRuntime()
|
||||
})
|
||||
}, 10_000)
|
||||
})
|
||||
onBeforeUnmount(() => window.clearInterval(kernelTimer))
|
||||
|
||||
const problems = computed(
|
||||
() => snapshot.value?.diagnostics.filter((d) => d.severity === 'error') ?? [],
|
||||
)
|
||||
@@ -101,11 +255,66 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button type="button" class="nav-item off" title="退出登录" @click="logout">
|
||||
<IconLogout class="nav-icon" />
|
||||
<span class="nav-label">退出登录</span>
|
||||
</button>
|
||||
|
||||
<!-- 配置动作一排。收拢时容器变窄,图标自动折行成一列 -->
|
||||
<div class="acts">
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
title="撤回"
|
||||
:disabled="busyAct || cooling.undo || !snapshot || history.backups.length === 0"
|
||||
@click="undo"
|
||||
>
|
||||
<IconUndo />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
title="重做"
|
||||
:disabled="busyAct || cooling.redo || !snapshot || history.redo === 0"
|
||||
@click="redo"
|
||||
>
|
||||
<IconRedo />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
title="历史记录"
|
||||
:disabled="!snapshot"
|
||||
@click="historyOpen = true"
|
||||
>
|
||||
<IconHistory />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="act"
|
||||
:title="kernel.online ? '更新配置(让内核重读配置文件)' : '内核未连接'"
|
||||
:disabled="busyAct || !snapshot || !kernel.online"
|
||||
@click="reloadFromDisk"
|
||||
>
|
||||
<IconReload />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="side-foot">
|
||||
<div class="kernel" :title="kernel.online ? kernel.version : '内核未连接'">
|
||||
<span :class="['dot', kernel.online ? 'dot-ok' : 'dot-off']" aria-hidden="true"></span>
|
||||
<span class="kernel-text mono">{{ kernel.online ? kernel.version : 'offline' }}</span>
|
||||
</div>
|
||||
<!-- 平时是内核状态,悬停变成重启入口。内核不在线时只当状态看 -->
|
||||
<button
|
||||
type="button"
|
||||
class="kernel-btn"
|
||||
:title="kernel.online ? '重启内核' : '内核未连接'"
|
||||
:disabled="!kernel.online"
|
||||
@click="askRestart = true"
|
||||
>
|
||||
<span class="k-idle">
|
||||
<span :class="['dot', kernel.online ? 'dot-ok' : 'dot-off']" aria-hidden="true"></span>
|
||||
<span class="kernel-text mono">{{ kernel.online ? kernel.version : 'offline' }}</span>
|
||||
</span>
|
||||
<span class="k-hot" aria-hidden="true"><IconRestart class="k-ico" /> 重启内核</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="densify"
|
||||
@@ -119,16 +328,16 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
</nav>
|
||||
|
||||
<main class="main">
|
||||
<p v-if="loading" class="note">读取中</p>
|
||||
<p v-if="!booted && refreshing" class="note">载入中</p>
|
||||
|
||||
<div v-else-if="loadError" class="alert">
|
||||
<h2>读不到配置</h2>
|
||||
<h2>配置读取失败</h2>
|
||||
<p>{{ loadError }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="snapshot">
|
||||
<div v-if="problems.length" class="alert">
|
||||
<h2>{{ problems.length }} 处问题</h2>
|
||||
<h2>配置存在 {{ problems.length }} 处错误</h2>
|
||||
<ul>
|
||||
<li v-for="(p, i) in problems" :key="i">
|
||||
<code>{{ p.path.join('.') }}</code> {{ p.message }}
|
||||
@@ -136,44 +345,125 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<RouterPage
|
||||
v-if="active === 'router'"
|
||||
<DashboardPage
|
||||
v-if="active === 'dashboard'"
|
||||
:data="snapshot.data"
|
||||
:revision="snapshot.revision"
|
||||
:kernel-online="kernel.online"
|
||||
:provider-lines="providerLines"
|
||||
:runtime="runtime"
|
||||
@changed="load"
|
||||
@open-inbound="openInbound"
|
||||
@open-router="active = 'router'"
|
||||
/>
|
||||
|
||||
<div v-else class="note">
|
||||
<RouterPage
|
||||
v-else-if="active === 'router'"
|
||||
:data="snapshot.data"
|
||||
:revision="snapshot.revision"
|
||||
:focus="routerFocus"
|
||||
@focus-done="routerFocus = null"
|
||||
@saved="load"
|
||||
/>
|
||||
|
||||
<ConfigPage
|
||||
v-else-if="active === 'config'"
|
||||
:data="snapshot.data"
|
||||
:revision="snapshot.revision"
|
||||
@saved="load"
|
||||
/>
|
||||
|
||||
<RulesPage
|
||||
v-else-if="active === 'rules'"
|
||||
:data="snapshot.data"
|
||||
:revision="snapshot.revision"
|
||||
@saved="load"
|
||||
/>
|
||||
|
||||
<SettingsPage
|
||||
v-else-if="active === 'settings'"
|
||||
:data="snapshot.data"
|
||||
:revision="snapshot.revision"
|
||||
@saved="load"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="active !== 'requests' && active !== 'connections'"
|
||||
class="note"
|
||||
>
|
||||
<component :is="current?.icon" class="note-icon" />
|
||||
<p>{{ current?.label }}还没做</p>
|
||||
<p>{{ current?.label }} 尚未实现</p>
|
||||
</div>
|
||||
|
||||
<!-- 请求页和连接页保活:切走不销毁,筛选词、排序、滚动位置都留住。
|
||||
KeepAlive 必须常驻、让子组件自己 v-if 进出——把 v-if 放在 KeepAlive
|
||||
上的话它随条件卸载,缓存跟着没了。轮询的起停走 activated 钩子 -->
|
||||
<KeepAlive>
|
||||
<RequestsPage v-if="active === 'requests'" />
|
||||
</KeepAlive>
|
||||
<KeepAlive>
|
||||
<ConnectionsPage v-if="active === 'connections'" />
|
||||
</KeepAlive>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<HistoryDialog :open="historyOpen" @close="historyOpen = false" @restored="load" />
|
||||
|
||||
<!-- 文案与仪表盘的重启确认一致,同一件事不说两种话 -->
|
||||
<ConfirmDialog
|
||||
:open="askRestart"
|
||||
message="重启内核会断开它当前的所有连接,几秒后自动恢复。"
|
||||
action="重启"
|
||||
@close="askRestart = false"
|
||||
@confirm="restartConfirmed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToastHost />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 侧栏折叠的做法:只过渡外层宽度,内层内容保持展开时的固定宽度、被 overflow 裁切,
|
||||
* 文字用透明度淡出。不用 display: none 藏文字——那会让内容在动画中途重排、图标跳位。
|
||||
* 60px 这个折叠宽度是算出来的:图标中心在 12+1+8+9=30px,正好是 60 的一半,
|
||||
* 收拢后图标视觉居中,全程不需要挪动任何东西。
|
||||
*/
|
||||
.shell {
|
||||
--side-w: 184px;
|
||||
display: grid;
|
||||
grid-template-columns: 184px 1fr;
|
||||
grid-template-columns: var(--side-w) 1fr;
|
||||
min-height: 100%;
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
.shell.is-compact {
|
||||
grid-template-columns: 56px 1fr;
|
||||
--side-w: 60px;
|
||||
}
|
||||
|
||||
/* ── 侧栏 ───────────────────────────────────── */
|
||||
/*
|
||||
* 钉在视口里。用 fixed 而不是 sticky:sticky 元素的位置跟着 scrollTop 走,
|
||||
* 而滚动到底时 scrollTop = scrollHeight - clientHeight,在 Windows 显示缩放下
|
||||
* 是小数——侧栏落在小数像素上,图标和文字取整方向不同,部分图标会跳 1px。
|
||||
* fixed 按视口摆放,不参与滚动偏移的运算。网格的第一列只当占位。
|
||||
*/
|
||||
.side {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--side-w);
|
||||
height: 100vh;
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-5);
|
||||
padding: var(--s-4) var(--s-3);
|
||||
border-right: 1px solid var(--c-border);
|
||||
background: var(--c-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 内容按展开宽度定死(184 - 24 内边距 - 1 边框),收拢时被裁掉而不是挤压换行 */
|
||||
.brand,
|
||||
.nav,
|
||||
.side-foot {
|
||||
min-width: 159px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -215,18 +505,32 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 收拢时淡出的文字。比外层宽度的 0.3s 快,字先消失、再收边,不露半截字 */
|
||||
.brand-name,
|
||||
.nav-label,
|
||||
.kernel-btn {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.is-compact .brand-name,
|
||||
.is-compact .nav-label,
|
||||
.is-compact .kernel-btn {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: var(--s-1);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
/* 写死而不是 100%:百分比到像素之间没法插值,宽度动画会直接跳变 */
|
||||
width: 159px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
@@ -238,6 +542,18 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
/* width 跟侧栏同节奏,颜色沿用全局的 0.15s;transition 不叠加,这里要写全 */
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* 收拢后按钮缩成正方形贴着图标,高亮块不再被裁切:8 + 18 + 8 加两侧边框 = 36 */
|
||||
.is-compact .nav-item {
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
@@ -257,21 +573,117 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* 退出登录:形态同导航项,但不参与「当前页」的高亮,收拢时同样缩成方块 */
|
||||
.off {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
/* 配置动作排:展开时一行五个,收拢时容器随 --side-w 变窄、flex 自动折成一列 */
|
||||
.acts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1);
|
||||
padding: 0 var(--s-2);
|
||||
width: calc(var(--side-w) - 2 * var(--s-3));
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.act:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.act:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.act svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.side-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-2);
|
||||
padding: 0 var(--s-2);
|
||||
/* 右边给绝对定位的折叠按钮留位置 */
|
||||
padding: 0 44px 0 var(--s-2);
|
||||
}
|
||||
|
||||
.kernel {
|
||||
/* 内核状态兼重启按钮:两层叠在同一格里,悬停换层 */
|
||||
.kernel-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
padding: 0 var(--s-2);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.kernel-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.kernel-btn:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.k-idle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
min-width: 0;
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
/* 等宽字体的行框上下不对称,压成 1 让点和文字真正居中 */
|
||||
line-height: 1;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.k-hot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: 0 var(--s-2);
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-xs);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.kernel-btn:hover:not(:disabled) .k-idle,
|
||||
.kernel-btn:focus-visible:not(:disabled) .k-idle {
|
||||
opacity: 0;
|
||||
}
|
||||
.kernel-btn:hover:not(:disabled) .k-hot,
|
||||
.kernel-btn:focus-visible:not(:disabled) .k-hot {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.k-ico {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
@@ -292,10 +704,14 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 钉在侧栏右下角:宽度过渡时自动跟着边缘走。right 取 17 是为了收拢到 60px 时正好居中 */
|
||||
.densify {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: absolute;
|
||||
right: 17px;
|
||||
bottom: var(--s-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid transparent;
|
||||
@@ -309,24 +725,11 @@ const current = computed(() => NAV.find((n) => n.key === active.value))
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
/* 紧凑模式:文字收起来,只留图标 */
|
||||
.is-compact .brand-name,
|
||||
.is-compact .nav-label,
|
||||
.is-compact .kernel-text {
|
||||
display: none;
|
||||
}
|
||||
.is-compact .brand,
|
||||
.is-compact .nav-item {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.is-compact .side-foot {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── 主区 ───────────────────────────────────── */
|
||||
/* 侧栏是 fixed、不占网格位,主区要自己站到第二列,否则会顶进第一列 */
|
||||
.main {
|
||||
grid-column: 2;
|
||||
padding: var(--s-6) var(--s-6) var(--s-7);
|
||||
max-width: 1600px;
|
||||
}
|
||||
|
||||
+251
-17
@@ -5,6 +5,10 @@
|
||||
* (或者持有 SSH 隧道)的人才打得开,存在浏览器里不额外扩大暴露面。
|
||||
*/
|
||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import type { ConnectionSnapshot } from '@shared/connections.ts'
|
||||
import type { ExitProbe, HostInfo } from '@shared/host-info.ts'
|
||||
import type { GeoFileStatus } from '@shared/geo.ts'
|
||||
import { DEFAULT_TEST_URL, testUrl } from './prefs'
|
||||
|
||||
const TOKEN_KEY = 'patchbay.token'
|
||||
|
||||
@@ -20,16 +24,30 @@ export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/** 接口报错时抛这个,界面据此决定是提示重填令牌还是显示错误内容。 */
|
||||
/**
|
||||
* 接口报错时抛这个。
|
||||
*
|
||||
* 服务端的错误响应统一是 { message, hint? }:message 说发生了什么,hint 说下一步该干嘛。
|
||||
* 界面直接用这两个字段,不需要认得每个端点各自的错误形状。
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly payload: Record<string, unknown>,
|
||||
) {
|
||||
super(typeof payload.message === 'string' ? payload.message : `请求失败(${String(status)})`)
|
||||
super(
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: `请求失败(HTTP ${String(status)})`,
|
||||
)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
|
||||
/** 服务端给的下一步建议,没有就是空串。 */
|
||||
get hint(): string {
|
||||
return typeof this.payload.hint === 'string' ? this.payload.hint : ''
|
||||
}
|
||||
|
||||
get unauthorized(): boolean {
|
||||
return this.status === 401
|
||||
}
|
||||
@@ -52,7 +70,6 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
data: ConfigObject
|
||||
yaml: string
|
||||
revision: string
|
||||
diagnostics: ConfigDiagnostic[]
|
||||
}
|
||||
@@ -64,15 +81,9 @@ export interface ApplySuccess {
|
||||
reloadMessage: string
|
||||
}
|
||||
|
||||
export interface ApplyFailure {
|
||||
ok: false
|
||||
stage: 'stale' | 'self-check' | 'kernel-check' | 'write' | 'reload'
|
||||
message: string
|
||||
hint: string
|
||||
restored: boolean
|
||||
}
|
||||
|
||||
export interface ProxyInfo {
|
||||
/** 出站的名字。/proxies 用键名标识,provider 里的线路则带在对象上 */
|
||||
name: string
|
||||
/** 协议类型(Vmess、Vless…)或策略组类型(Selector、URLTest…) */
|
||||
type: string
|
||||
/** 策略组当前选中的成员。不是策略组就没有这个字段 */
|
||||
@@ -89,29 +100,252 @@ export function latestDelay(info: ProxyInfo | undefined): number | null {
|
||||
return last === undefined || last === 0 ? null : last
|
||||
}
|
||||
|
||||
/** 一个订阅的运行时状态。updatedAt 是上次拉取成功的时刻,没拉过是 Go 的零值时间。 */
|
||||
export interface ProviderInfo {
|
||||
vehicleType: string
|
||||
updatedAt?: string
|
||||
proxies: ProxyInfo[]
|
||||
}
|
||||
|
||||
export interface FieldSpec {
|
||||
key: string
|
||||
type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'object' | 'object[]'
|
||||
required: boolean
|
||||
default: unknown
|
||||
enum: (string | number)[] | null
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface ListenerFields {
|
||||
common: FieldSpec[]
|
||||
types: Record<string, { note: string; fields: FieldSpec[] }>
|
||||
}
|
||||
|
||||
export interface RuleProviderInfo {
|
||||
/** domain / ipcidr / classical */
|
||||
behavior: string
|
||||
/** yaml / text / mrs。内核始终序列化它(provider.go:37 无 omitempty),inline 类型给的是空串 */
|
||||
format?: string
|
||||
name: string
|
||||
/** 当前装了多少条规则 */
|
||||
ruleCount: number
|
||||
/** HTTP / File / Inline */
|
||||
vehicleType: string
|
||||
/** RFC3339。还没拉取过时是零值(0001-01-01) */
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const api = {
|
||||
readConfig: () => request<ConfigSnapshot>('GET', '/api/config'),
|
||||
|
||||
applyConfig: (data: ConfigObject, baseRevision: string) =>
|
||||
request<ApplySuccess>('PUT', '/api/config', { data, baseRevision }),
|
||||
|
||||
listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'),
|
||||
/** 备份链(撤回栈)和重做栈深度。侧栏用它决定撤回/重做按钮亮不亮。 */
|
||||
listBackups: () => request<{ backups: string[]; redo: number }>('GET', '/api/config/backups'),
|
||||
|
||||
/** 撤回最近一次改动:应用最新备份并消费掉它,撤回前的状态进重做栈。 */
|
||||
undoConfig: () => request<ApplySuccess>('POST', '/api/config/undo'),
|
||||
|
||||
/** 重做最近一次被撤回的改动。新的正常保存会清空重做栈。 */
|
||||
redoConfig: () => request<ApplySuccess>('POST', '/api/config/redo'),
|
||||
|
||||
/** 配置文件的原文,给「下载当前配置」用。 */
|
||||
configRaw: async (): Promise<string> => {
|
||||
const res = await fetch('/api/config/raw', {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
|
||||
throw new ApiError(res.status, payload)
|
||||
}
|
||||
return res.text()
|
||||
},
|
||||
|
||||
/**
|
||||
* 用上传的 YAML 替换当前配置:override 整份替换,compatible 只搬
|
||||
* Patchbay 接管的段落。走服务端完整的保护链路(校验、备份、重载、失败回退)。
|
||||
*/
|
||||
importConfig: (content: string, mode: 'override' | 'compatible') =>
|
||||
request<ApplySuccess>('POST', '/api/config/import', { content, mode }),
|
||||
|
||||
restoreBackup: (name: string) =>
|
||||
request<ApplySuccess>('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`),
|
||||
|
||||
/** 一条历史记录的对比原文:这份备份(改动前)和它之后的状态(改动后)。 */
|
||||
backupDiff: (name: string) =>
|
||||
request<{ before: string; after: string }>(
|
||||
'GET',
|
||||
`/api/config/backups/${encodeURIComponent(name)}/diff`,
|
||||
),
|
||||
|
||||
kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'),
|
||||
|
||||
/**
|
||||
* 服务端所在主机的指标:CPU、内存、负载、内网地址、网关。
|
||||
* 内核的管理接口只报自己的内存,这些只有跑在同一台机器上的 Patchbay 服务端答得出。
|
||||
*/
|
||||
host: () => request<HostInfo>('GET', '/api/host'),
|
||||
|
||||
/**
|
||||
* 经由某个入站端口探测公网 IP(服务端拿本机对应端口当代理去访问 ip.sb)。
|
||||
* 会发起真实的外网请求,只在用户点了检测时调。
|
||||
*/
|
||||
probeExit: (port: number) => request<ExitProbe>('GET', `/api/host/exit?port=${String(port)}`),
|
||||
|
||||
/** 内核的运行时设置:运行模式、各代理端口、allow-lan 等。 */
|
||||
kernelConfigs: () => request<Record<string, unknown>>('GET', '/api/kernel/configs'),
|
||||
|
||||
/**
|
||||
* 重启内核进程。所有连接会断开,几秒后自动恢复;
|
||||
* 策略组的选中靠配置里的 profile.store-selected 找回来。
|
||||
*/
|
||||
restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'),
|
||||
|
||||
/** 让内核重新加载磁盘上的配置文件。给 SSH 手改过配置的场景用。 */
|
||||
reloadKernelConfig: () => request<{ ok: true }>('POST', '/api/kernel/reload'),
|
||||
|
||||
/**
|
||||
* 让内核更新 GEO 数据库。同步接口,下载完才回话,可能要等上几十秒。
|
||||
* 内核只有全量更新这一个口子,没变化的文件靠内容哈希跳过重写。
|
||||
*/
|
||||
updateGeo: () => request<{ ok: true }>('POST', '/api/kernel/geo'),
|
||||
|
||||
/** 四类 GEO 文件的本地状态:大小、更新时间、生效中的下载地址。 */
|
||||
geoStatus: () => request<{ files: GeoFileStatus[] }>('GET', '/api/geo'),
|
||||
|
||||
/**
|
||||
* 让服务端下载单个 GEO 文件(内核的更新接口是全量的,单文件只能自己下)。
|
||||
* 内核缓存着旧数据,重启后才换用新文件。
|
||||
*/
|
||||
syncGeoFile: (configKey: string) =>
|
||||
request<{ ok: true }>('POST', '/api/geo/sync', { key: configKey }),
|
||||
|
||||
/**
|
||||
* 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。
|
||||
*
|
||||
* 不能用上面的 request():它会把整个响应读完,而对永不结束的流来说那等于挂死。
|
||||
* 调用方拿到响应体后用 web/stream.ts 的 readLines() 逐行读;中止 signal 即断流。
|
||||
*/
|
||||
openStream: async (path: string, signal: AbortSignal): Promise<ReadableStream<Uint8Array>> => {
|
||||
const res = await fetch(`/api/kernel${path}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
const payload = text ? (JSON.parse(text) as Record<string, unknown>) : {}
|
||||
throw new ApiError(res.status, payload)
|
||||
}
|
||||
if (!res.body) throw new Error('响应没有内容')
|
||||
return res.body
|
||||
},
|
||||
|
||||
/**
|
||||
* 把上传的线路/规则清单写进内核工作目录的 providers/ 下。
|
||||
* path 必须是 providerCachePath / ruleProviderPath 生成的形状,服务端有白名单校验。
|
||||
*/
|
||||
uploadProviderFile: (path: string, content: string) =>
|
||||
request<{ ok: true }>('POST', '/api/provider-files', { path, content }),
|
||||
|
||||
/** 入站类型的字段表,从内核源码提取,用来驱动表单。 */
|
||||
listenerFields: () => request<ListenerFields>('GET', '/api/meta/listener-fields'),
|
||||
|
||||
/**
|
||||
* 所有出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。
|
||||
* 这些配置文件里都没有,只能问运行中的内核。
|
||||
*/
|
||||
proxies: () => request<{ proxies: Record<string, ProxyInfo> }>('GET', '/api/kernel/proxies'),
|
||||
|
||||
/** 订阅里当前有哪些线路。配置文件给不出这个答案,只能问运行中的内核。 */
|
||||
providers: () =>
|
||||
request<{ providers: Record<string, { vehicleType: string; proxies: { name: string }[] }> }>(
|
||||
'GET',
|
||||
'/api/kernel/providers/proxies',
|
||||
/**
|
||||
* 切换某个策略组当前选中哪个成员。
|
||||
* 这是运行时状态,不写配置文件,立刻生效——metacubexd 里点一条线路做的就是这件事。
|
||||
*/
|
||||
selectProxy: (group: string, member: string) =>
|
||||
request<{ ok: true }>('PUT', `/api/kernel/proxies/${encodeURIComponent(group)}`, {
|
||||
name: member,
|
||||
}),
|
||||
|
||||
/**
|
||||
* 测一条出站的延迟。
|
||||
*
|
||||
* 走哪个接口取决于它在不在内核的全局名字表里:策略组和自有线路在表里,用 /proxies;
|
||||
* 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。
|
||||
*/
|
||||
testLine: (name: string, provider?: string, timeout = 5000) => {
|
||||
// 测速地址是设置页里的偏好,空值退回默认
|
||||
const target = testUrl.value.trim() || DEFAULT_TEST_URL
|
||||
const q = `url=${encodeURIComponent(target)}&timeout=${String(timeout)}`
|
||||
const path = provider
|
||||
? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}`
|
||||
: `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}`
|
||||
return request<{ delay: number }>('GET', path)
|
||||
},
|
||||
|
||||
/**
|
||||
* 当前所有连接的快照。
|
||||
*
|
||||
* 这个接口没有流式版本(流式的是 /traffic、/logs 那几个),只能全量轮询。
|
||||
* 没有连接时 connections 字段是 null 而不是空数组。
|
||||
*/
|
||||
connections: () => request<ConnectionSnapshot>('GET', '/api/kernel/connections'),
|
||||
|
||||
/** 断开某一条连接。运行时动作,不写配置文件。 */
|
||||
closeConnection: (id: string) =>
|
||||
request<{ message?: string }>(
|
||||
'DELETE',
|
||||
`/api/kernel/connections/${encodeURIComponent(id)}`,
|
||||
),
|
||||
|
||||
/** 断开所有连接。 */
|
||||
closeAllConnections: () => request<{ message?: string }>('DELETE', '/api/kernel/connections'),
|
||||
|
||||
/**
|
||||
* 让内核立刻重新拉取某个订阅。
|
||||
*
|
||||
* 运行时动作,不写配置文件,所以不走 applyConfig 那条校验重载链路。成功时内核回
|
||||
* 204 空响应,拉取失败回 503 带错误消息(内核源码 hub/route/provider.go:52-60)。
|
||||
*/
|
||||
refreshProvider: (name: string) =>
|
||||
request<unknown>('PUT', `/api/kernel/providers/proxies/${encodeURIComponent(name)}`),
|
||||
|
||||
/**
|
||||
* 让内核立刻对一个订阅的全部线路做一次健康检查:逐条 URLTest,测通与否和延迟一起
|
||||
* 记进历史(内核源码 adapter/provider/healthcheck.go:123-189)。
|
||||
*
|
||||
* 用的是这个订阅 `health-check.url` 里的地址而不是设置页的测速地址——内核这个接口
|
||||
* 不收 url 参数。它是同步的,测完全部线路才回话,所以可能要等上几十秒。
|
||||
*/
|
||||
checkProviderLines: (name: string) =>
|
||||
request<unknown>(
|
||||
'GET',
|
||||
`/api/kernel/providers/proxies/${encodeURIComponent(name)}/healthcheck`,
|
||||
),
|
||||
|
||||
/**
|
||||
* 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。
|
||||
* 配置文件里只有声明,这两个数字只有运行中的内核知道。
|
||||
*/
|
||||
ruleProviders: () =>
|
||||
request<{ providers: Record<string, RuleProviderInfo> }>('GET', '/api/kernel/providers/rules'),
|
||||
|
||||
/**
|
||||
* 让内核立刻重新拉取某个规则组。
|
||||
* 这是运行时动作,不写配置文件,所以不走落盘那条保护链路。
|
||||
*/
|
||||
updateRuleProvider: (name: string) =>
|
||||
request<Record<string, never>>(
|
||||
'PUT',
|
||||
`/api/kernel/providers/rules/${encodeURIComponent(name)}`,
|
||||
),
|
||||
|
||||
/**
|
||||
* 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。
|
||||
*
|
||||
* 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张
|
||||
* 全局名字表里,这是拿到它们类型和延迟的唯一途径。
|
||||
*/
|
||||
providers: () =>
|
||||
request<{ providers: Record<string, ProviderInfo> }>('GET', '/api/kernel/providers/proxies'),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 可自由填写的组合框,替代原生 input + datalist。
|
||||
*
|
||||
* 和 SelectBox 的分工:SelectBox 的值只能从选项里挑;这里选项只是建议,
|
||||
* 手填任何值都合法(规则的目标可以是候选之外的出站名)。换掉 datalist
|
||||
* 的理由和 SelectBox 一样:原生建议列表的样式、动画、开合状态都管不到。
|
||||
*
|
||||
* 过滤只在本次展开后输入过才生效:带着已有值展开时按值过滤的话,
|
||||
* 列表只剩当前值自己,其它选项等于不存在。
|
||||
*
|
||||
* Enter 的归属:弹层开着且有高亮项时选中它、不提交表单;弹层收起时不拦,
|
||||
* 交给对话框的表单提交——填完直接回车保存是原生 input 的习惯,不能破坏。
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import IconChevron from '~icons/ri/arrow-down-s-line'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 建议列表。过滤是 includes,不区分大小写 */
|
||||
options: string[]
|
||||
placeholder?: string
|
||||
/** 等宽字体,规则原文这类内容用 */
|
||||
mono?: boolean
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const text = defineModel<string>({ default: '' })
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
const openUp = ref(false)
|
||||
const active = ref(0)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
/** 本次展开后是否输入过。没输入过就给全量列表 */
|
||||
const typed = ref(false)
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = text.value.trim().toLowerCase()
|
||||
if (!typed.value || !q) return props.options
|
||||
return props.options.filter((name) => name.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const MENU_ROOM = 220
|
||||
|
||||
/**
|
||||
* 弹层是 position: fixed,按视口摆放,理由同 SelectBox:absolute 会计入
|
||||
* 祖先滚动容器的可滚动区域,在对话框里打开会把对话框撑出滚动条。
|
||||
* 位置不跟随滚动,所以滚动和缩放时直接收起。
|
||||
*/
|
||||
function place() {
|
||||
const rect = root.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const up = window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
|
||||
openUp.value = up
|
||||
menuStyle.value = {
|
||||
left: `${String(rect.left)}px`,
|
||||
minWidth: `${String(rect.width)}px`,
|
||||
...(up
|
||||
? { bottom: `${String(window.innerHeight - rect.top + 4)}px` }
|
||||
: { top: `${String(rect.bottom + 4)}px` }),
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面或对话框一滚动,锚点就不准了,直接收起。弹层内部的滚动不算 */
|
||||
function closeOnMove(event?: Event) {
|
||||
if (event?.target instanceof Node && menuEl.value?.contains(event.target)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
window.addEventListener('scroll', closeOnMove, true)
|
||||
window.addEventListener('resize', closeOnMove)
|
||||
} else {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
})
|
||||
|
||||
function show() {
|
||||
typed.value = false
|
||||
active.value = 0
|
||||
place()
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
typed.value = true
|
||||
active.value = 0
|
||||
place()
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function toggleFromChevron() {
|
||||
if (open.value) open.value = false
|
||||
else show()
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
function pick(name: string) {
|
||||
text.value = name
|
||||
open.value = false
|
||||
input.value?.focus()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!open.value) {
|
||||
show()
|
||||
return
|
||||
}
|
||||
const count = filtered.value.length
|
||||
if (count === 0) return
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
active.value = (active.value + delta + count) % count
|
||||
} else if (event.key === 'Enter') {
|
||||
if (!open.value) return // 交给表单提交
|
||||
const name = filtered.value[active.value]
|
||||
event.preventDefault()
|
||||
if (name) pick(name)
|
||||
else open.value = false
|
||||
} else if (event.key === 'Escape' && open.value) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && root.value?.contains(next)) return
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="combo" @focusout="onFocusOut">
|
||||
<input
|
||||
ref="input"
|
||||
v-model="text"
|
||||
:class="['field', mono && 'mono']"
|
||||
type="text"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:aria-label="ariaLabel"
|
||||
:placeholder="placeholder"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
@focus="show"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:aria-label="open ? '收起建议' : '展开建议'"
|
||||
tabindex="-1"
|
||||
@click="toggleFromChevron"
|
||||
>
|
||||
<IconChevron :class="['chev', open && 'is-open']" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Transition name="menu">
|
||||
<ul
|
||||
v-if="open && filtered.length > 0"
|
||||
ref="menuEl"
|
||||
:class="['menu', openUp && 'is-up']"
|
||||
:style="menuStyle"
|
||||
role="listbox"
|
||||
>
|
||||
<li v-for="(name, i) in filtered" :key="name">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="name === text"
|
||||
:class="['opt', i === active && 'is-active']"
|
||||
@mousemove="active = i"
|
||||
@click="pick(name)"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.combo {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
/* 给右侧箭头留位置 */
|
||||
padding-right: var(--s-6);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--select-bg, var(--c-bg));
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--s-2);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chev {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chev.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 位置(left/top/bottom/minWidth)由 place() 以内联样式给出 */
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
/* 细杆加透明轨道,别让弹层里出现一根系统灰滚动条 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s-1);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-size: var(--t-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opt.is-active {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.menu-enter-active,
|
||||
.menu-leave-active {
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.menu-enter-from,
|
||||
.menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.menu.is-up.menu-enter-from,
|
||||
.menu.is-up.menu-leave-to {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 危险动作的二次确认。
|
||||
*
|
||||
* 原生 <dialog>,焦点捕获、Esc 关闭、背景遮罩都是浏览器自带的(同 InboundForm)。
|
||||
* 不用 window.confirm 是因为它的样式游离在页面之外,按钮文案也固定是「确定」,
|
||||
* 对不上具体要做的动作。打开时焦点落在「取消」上,回车不会误删。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
message: string
|
||||
/** 确认按钮上的动词,比如「删除」 */
|
||||
action: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: []; confirm: [] }>()
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) dialog.value?.showModal()
|
||||
else dialog.value?.close()
|
||||
},
|
||||
)
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是 .body 里的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel.prevent="emit('close')"
|
||||
>
|
||||
<div class="body">
|
||||
<p class="msg">{{ message }}</p>
|
||||
<footer class="acts">
|
||||
<button type="button" class="btn" @click="emit('close')">取消</button>
|
||||
<button type="button" class="btn go" @click="emit('confirm')">{{ action }}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg {
|
||||
width: min(400px, calc(100vw - 2 * var(--s-5)));
|
||||
/* 内边距放在 .body 上:dialog 自己的内边距区域点了会被当成遮罩点击 */
|
||||
padding: 0;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--s-5);
|
||||
}
|
||||
|
||||
.dlg::backdrop {
|
||||
background: rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
.msg {
|
||||
line-height: var(--line);
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-2);
|
||||
margin-top: var(--s-5);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--s-2) var(--s-4);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 确认键用危险色实底:这个对话框只出现在删除这类不可逆动作前 */
|
||||
.go {
|
||||
border-color: transparent;
|
||||
background: var(--c-danger);
|
||||
color: var(--c-bg);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 拖拽或点选一个文件,读出文本内容交给调用方。
|
||||
*
|
||||
* 无状态:只发 load 事件,选没选、选了什么由调用方自己记——三个用它的表单
|
||||
* 对「选中之后长什么样」要求各不相同(订阅显示文件名待提交,ini 导入直接
|
||||
* 灌进文本框),状态放这里反而要为每种用法开口子。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import IconUpload from '~icons/ri/upload-cloud-2-line'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 原生 accept 串,如 ".yaml,.yml,.txt" */
|
||||
accept?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ load: [name: string, content: string] }>()
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const dragging = ref(false)
|
||||
|
||||
async function take(file: File | undefined) {
|
||||
if (!file) return
|
||||
emit('load', file.name, await file.text())
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragging.value = false
|
||||
void take(event.dataTransfer?.files[0])
|
||||
}
|
||||
|
||||
function onPick(event: Event) {
|
||||
const el = event.target as HTMLInputElement
|
||||
void take(el.files?.[0])
|
||||
// 同一个文件选第二次也要触发 change
|
||||
el.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="['zone', dragging && 'is-over']"
|
||||
@click="input?.click()"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave="dragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<IconUpload class="ico" aria-hidden="true" />
|
||||
<span>拖拽文件到这里,或点击选择</span>
|
||||
<input ref="input" type="file" :accept="props.accept" hidden @change="onPick" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-2);
|
||||
width: 100%;
|
||||
padding: var(--s-4);
|
||||
border: 1px dashed var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.zone:hover,
|
||||
.zone.is-over {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
background: var(--c-accent-soft);
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ECharts 的最小封装:初始化、跟随 option 更新、跟随容器尺寸缩放、卸载时销毁。
|
||||
*
|
||||
* 按需注册而不是整包引入:仪表盘只用折线和环形图。调用方传新的 option 对象
|
||||
* 触发更新(computed 每次重算都是新引用),这里不做深比较。
|
||||
*/
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart, PieChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import type { EChartsCoreOption } from 'echarts/core'
|
||||
|
||||
echarts.use([LineChart, PieChart, GridComponent, TooltipComponent, CanvasRenderer])
|
||||
|
||||
const props = defineProps<{ option: EChartsCoreOption }>()
|
||||
|
||||
const el = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.EChartsType | null = null
|
||||
let observer: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!el.value) return
|
||||
chart = echarts.init(el.value)
|
||||
chart.setOption(props.option)
|
||||
observer = new ResizeObserver(() => chart?.resize())
|
||||
observer.observe(el.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.option,
|
||||
(option) => chart?.setOption(option),
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" class="echart"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.echart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 备份链(历史记录)的弹窗:列出每次改动前留下的备份,点还原直接跳回去。
|
||||
* 还原走服务端同一条保护链路,当前状态会先自动备份,所以不需要再套一层确认。
|
||||
* 点一行展开这次改动的 diff:改动前是备份本身,改动后是更新一份的状态。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { backupTime } from '@shared/backups.ts'
|
||||
import { diffHunks, diffLines } from '@shared/diff.ts'
|
||||
import type { DiffLine } from '@shared/diff.ts'
|
||||
import { api } from '../api'
|
||||
import { toast, toastError } from '../toast'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||
|
||||
const props = defineProps<{ open: boolean }>()
|
||||
|
||||
/** restored:还原成功,外壳该重读配置了。 */
|
||||
const emit = defineEmits<{ close: []; restored: [] }>()
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const backups = ref<string[] | null>(null)
|
||||
const busy = ref(false)
|
||||
|
||||
/** 展开着的那条。同时只开一条,diff 是逐条看的东西,摊开一串没法读 */
|
||||
const expanded = ref<string | null>(null)
|
||||
/** 已取回并算好的 diff 片段。null 表示取失败了 */
|
||||
const diffs = ref<Record<string, DiffLine[][] | null>>({})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
dialog.value?.showModal()
|
||||
backups.value = null
|
||||
expanded.value = null
|
||||
diffs.value = {}
|
||||
void api
|
||||
.listBackups()
|
||||
.then((r) => (backups.value = r.backups))
|
||||
.catch(() => (backups.value = []))
|
||||
} else {
|
||||
dialog.value?.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function toggleDiff(name: string) {
|
||||
expanded.value = expanded.value === name ? null : name
|
||||
if (expanded.value !== name || name in diffs.value) return
|
||||
try {
|
||||
const { before, after } = await api.backupDiff(name)
|
||||
diffs.value = { ...diffs.value, [name]: diffHunks(diffLines(before, after)) }
|
||||
} catch {
|
||||
diffs.value = { ...diffs.value, [name]: null }
|
||||
}
|
||||
}
|
||||
|
||||
const sign = (kind: DiffLine['kind']) => (kind === 'add' ? '+' : kind === 'del' ? '−' : ' ')
|
||||
|
||||
async function restore(name: string) {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await api.restoreBackup(name)
|
||||
const time = backupTime(name) ?? name
|
||||
toast(result.reloaded ? `已还原到 ${time}` : `已还原到 ${time}(${result.reloadMessage})`)
|
||||
emit('restored')
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel.prevent="emit('close')"
|
||||
>
|
||||
<div class="body">
|
||||
<header class="head">
|
||||
<h2>历史记录</h2>
|
||||
<button type="button" class="x" title="关闭" @click="emit('close')"><IconClose /></button>
|
||||
</header>
|
||||
|
||||
<p v-if="backups === null" class="faint">载入中</p>
|
||||
<p v-else-if="backups.length === 0" class="faint">
|
||||
没有备份。每次保存都会把改动前的状态留在这里,最多 10 份。
|
||||
</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="name in backups" :key="name" class="item">
|
||||
<div
|
||||
class="row"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="name"
|
||||
@click="toggleDiff(name)"
|
||||
@keydown.enter="toggleDiff(name)"
|
||||
>
|
||||
<IconRight :class="['chev', expanded === name && 'is-open']" aria-hidden="true" />
|
||||
<span class="mono time">{{ backupTime(name) ?? name }}</span>
|
||||
<button type="button" class="btn" :disabled="busy" @click.stop="restore(name)">
|
||||
还原
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded === name" class="diff mono">
|
||||
<p v-if="!(name in diffs)" class="faint">载入中</p>
|
||||
<p v-else-if="diffs[name] === null" class="faint">对比内容读取失败</p>
|
||||
<p v-else-if="diffs[name]!.length === 0" class="faint">和之后的状态没有差异</p>
|
||||
<template v-else>
|
||||
<template v-for="(hunk, hi) in diffs[name]" :key="hi">
|
||||
<div v-if="hi > 0" class="gap" aria-hidden="true">⋯</div>
|
||||
<div v-for="(line, li) in hunk" :key="li" :class="['dl', line.kind]">
|
||||
<span class="dsign">{{ sign(line.kind) }}</span>{{ line.text }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg {
|
||||
width: min(640px, calc(100vw - 2 * var(--s-5)));
|
||||
padding: 0;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.dlg::backdrop {
|
||||
background: rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--s-5);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
font-size: var(--t-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.x {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.x:hover {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.faint {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chev {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--c-text-faint);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.chev.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: 1;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--s-1) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
font-size: var(--t-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
.btn:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.diff {
|
||||
margin: 0 0 var(--s-3);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-bg);
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
font-size: var(--t-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dl {
|
||||
white-space: pre;
|
||||
}
|
||||
.dl.add {
|
||||
color: var(--c-ok);
|
||||
}
|
||||
.dl.del {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.dl.same {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.dsign {
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.gap {
|
||||
padding: var(--s-1) 0;
|
||||
color: var(--c-text-faint);
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 入站列表。每行一路入站:端口、类型、名字、它落在哪个出站上。
|
||||
*
|
||||
* 只管显示和选中,不碰任何数据。
|
||||
*/
|
||||
import { inboundTypeLabel, isUnsetOutbound, outboundLabel } from '@shared/inbounds.ts'
|
||||
import { displayName, isGenerated } from '@shared/inbound-groups.ts'
|
||||
import { resolveChain } from '@shared/outbound-chain.ts'
|
||||
import type { Inbound } from '@shared/inbounds.ts'
|
||||
import { isChainedLine, runtime } from '../runtime'
|
||||
import IconLink from '~icons/ri/links-line'
|
||||
|
||||
defineProps<{ inbounds: Inbound[] }>()
|
||||
const selected = defineModel<string | null>('selected', { required: true })
|
||||
|
||||
/**
|
||||
* 这一行右侧显示什么。
|
||||
*
|
||||
* 指向 Patchbay 自动创建的代理组时,顺着「当前选中」解析到最终出口——组名和左边的
|
||||
* 名字列完全重复,而组选中 AUTO 时「AUTO」也不是答案,用户要的是流量走哪条线。
|
||||
* 出口经过链式代理入口时带一个标记,和详情里的流量去向对得上。
|
||||
*/
|
||||
function outboundOf(inbound: Inbound): { text: string; chained: boolean } {
|
||||
const mode = inbound.outbound
|
||||
if (mode.kind !== 'named' || !isGenerated(mode.name))
|
||||
return { text: outboundLabel(mode), chained: false }
|
||||
const exit = resolveChain(runtime.value, runtime.value[mode.name]?.now ?? null).at(-1)
|
||||
return {
|
||||
text: displayName(exit ?? '') || '未选择',
|
||||
chained: exit !== undefined && isChainedLine(exit),
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol class="rows">
|
||||
<li v-for="inbound in inbounds" :key="inbound.id">
|
||||
<button
|
||||
type="button"
|
||||
:class="['line', selected === inbound.id && 'is-on']"
|
||||
@click="selected = inbound.id"
|
||||
>
|
||||
<span class="port mono">{{ inbound.port }}</span>
|
||||
<span class="type mono">{{ inboundTypeLabel(inbound.type) }}</span>
|
||||
<span class="name">
|
||||
{{ inbound.name }}
|
||||
<span v-if="inbound.isDefault" class="tag">legacy</span>
|
||||
</span>
|
||||
<span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']">
|
||||
<span v-if="outboundOf(inbound).chained" class="chain" title="经过链式代理">
|
||||
<IconLink />
|
||||
</span>
|
||||
{{ outboundOf(inbound).text }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rows li + li {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem 5rem 1fr minmax(5rem, auto);
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.line:hover {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.line.is-on {
|
||||
background: var(--c-accent-soft);
|
||||
}
|
||||
|
||||
.port {
|
||||
font-size: var(--t-lg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.type {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
flex: none;
|
||||
padding: 0 var(--s-2);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 999px;
|
||||
font-size: var(--t-xs);
|
||||
color: var(--c-text-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.out {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.chain {
|
||||
display: flex;
|
||||
color: var(--c-chain-text);
|
||||
}
|
||||
|
||||
.out.is-muted {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 「纯文本 + 编辑铅笔」的行内编辑:平时只显示值,点铅笔换成输入框,
|
||||
* Enter 或失焦提交,Esc 放弃。设置页里一行一个值的场合都用它,
|
||||
* 免得整页铺满常驻的输入框。
|
||||
*
|
||||
* 校验交给 validate:Enter 提交没过就地标红不退出;失焦没过直接放弃改动
|
||||
* ——失焦是「我走了」,不该把人拽回来。
|
||||
*/
|
||||
import { nextTick, ref } from 'vue'
|
||||
import IconEdit from '~icons/ri/pencil-line'
|
||||
import IconReset from '~icons/ri/arrow-go-back-line'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 当前值的原始文本,空串表示未设置 */
|
||||
value: string
|
||||
/** 空值时显示的说明(「未启用」「内核默认」) */
|
||||
placeholder?: string
|
||||
/** 值后面的单位 */
|
||||
unit?: string
|
||||
mono?: boolean
|
||||
/** 显示「恢复默认」按钮 */
|
||||
canReset?: boolean
|
||||
/** 提交进行中,禁用编辑入口 */
|
||||
busy?: boolean
|
||||
validate?: (value: string) => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ submit: [value: string]; reset: [] }>()
|
||||
|
||||
const editing = ref(false)
|
||||
const draft = ref('')
|
||||
const bad = ref(false)
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
|
||||
async function open() {
|
||||
draft.value = props.value
|
||||
bad.value = false
|
||||
editing.value = true
|
||||
await nextTick()
|
||||
inputEl.value?.focus()
|
||||
inputEl.value?.select()
|
||||
}
|
||||
|
||||
/** lenient 是失焦路径:无效就放弃,不纠缠。 */
|
||||
function finish(lenient: boolean) {
|
||||
if (!editing.value) return
|
||||
const next = draft.value.trim()
|
||||
if (props.validate && !props.validate(next)) {
|
||||
if (lenient) editing.value = false
|
||||
else bad.value = true
|
||||
return
|
||||
}
|
||||
editing.value = false
|
||||
if (next !== props.value) emit('submit', next)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ie">
|
||||
<template v-if="!editing">
|
||||
<span :class="['val', mono && 'mono', !value && 'faint']" :title="value || undefined">
|
||||
{{ value || placeholder }}
|
||||
</span>
|
||||
<span v-if="unit && value" class="unit">{{ unit }}</span>
|
||||
<button
|
||||
v-if="canReset"
|
||||
type="button"
|
||||
class="ico"
|
||||
title="恢复默认"
|
||||
:disabled="busy"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
<IconReset />
|
||||
</button>
|
||||
<button type="button" class="ico" title="编辑" :disabled="busy" @click="open">
|
||||
<IconEdit />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="draft"
|
||||
:class="['input', mono && 'mono', bad && 'is-bad']"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
@keydown.enter.prevent="finish(false)"
|
||||
@keydown.esc.prevent="cancel"
|
||||
@blur="finish(true)"
|
||||
/>
|
||||
<span v-if="unit" class="unit">{{ unit }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ie {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.val {
|
||||
font-size: var(--t-sm);
|
||||
max-width: var(--ie-max, 320px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.faint {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.unit {
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.ico {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ico:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.ico:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ico svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: var(--ie-width, 220px);
|
||||
max-width: 100%;
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.input.is-bad {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 一个出站选项:策略组、线路,或者 RULE/DIRECT 这类内置出站。
|
||||
*
|
||||
* 左下角那格是延迟:测过就显示毫秒数,没测过显示一个闪电。两种情况都能点,
|
||||
* 点了就测这一条——「没数字」和「想重测」需要的是同一个动作,没必要做成两个控件。
|
||||
*/
|
||||
import IconFlash from '~icons/ri/flashlight-line'
|
||||
import IconLoad from '~icons/ri/loader-4-line'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
/** 第二行。策略组显示「类型(当前选中)」,线路显示协议 */
|
||||
sub: string
|
||||
/** 最近一次延迟,毫秒。没测过或测失败是 null */
|
||||
delay: number | null
|
||||
selected: boolean
|
||||
/** 不可选时的原因。有值就说明这张卡点不了 */
|
||||
disabledReason?: string
|
||||
/** 这张卡的改动正在提交 */
|
||||
pending?: boolean
|
||||
/** 草稿预览里保存后才加入的成员 */
|
||||
draft?: boolean
|
||||
/** 草稿预览里保存后将移除的成员 */
|
||||
removed?: boolean
|
||||
/** 这条线路正在测延迟 */
|
||||
testing?: boolean
|
||||
/** 能不能测延迟。内置出站没得测 */
|
||||
testable?: boolean
|
||||
/**
|
||||
* 选中态用哪一组颜色。同一屏上有两种「当前选中」,一种是流量最后从哪出去,
|
||||
* 一种是流量先经过哪,不区分颜色就分不清哪张卡属于哪一件事。
|
||||
*/
|
||||
tone?: 'proxy' | 'chain'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ pick: []; test: [] }>()
|
||||
|
||||
/**
|
||||
* foot 的空白处也算选择。它属于卡片,点了没反应会让人以为卡片坏了;
|
||||
* 延迟按钮自己 stop 掉冒泡,测延迟不会连带换线。
|
||||
*/
|
||||
function pickFromFoot() {
|
||||
if (!props.disabledReason) emit('pick')
|
||||
}
|
||||
|
||||
function delayClass(ms: number | null): string {
|
||||
if (ms === null) return ''
|
||||
if (ms < 200) return 'd-ok'
|
||||
if (ms < 500) return 'd-warn'
|
||||
return 'd-bad'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'card',
|
||||
tone === 'chain' && 'tone-chain',
|
||||
selected && 'is-on',
|
||||
disabledReason && 'is-off',
|
||||
pending && 'is-pending',
|
||||
draft && 'is-draft',
|
||||
removed && 'is-removed',
|
||||
!testable && !pending && 'no-foot',
|
||||
]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="hit"
|
||||
:disabled="!!disabledReason"
|
||||
:title="disabledReason"
|
||||
@click="emit('pick')"
|
||||
>
|
||||
<span class="name">{{ name }}</span>
|
||||
<span class="sub">{{ sub || '—' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 没内容就不渲染:空的 foot 会盖在卡片下缘,挡住主按钮的点击 -->
|
||||
<div v-if="testable || pending" class="foot" @click="pickFromFoot">
|
||||
<button
|
||||
v-if="testable"
|
||||
type="button"
|
||||
:class="['delay', delayClass(delay)]"
|
||||
:disabled="testing"
|
||||
:title="delay === null ? '测试延迟' : '重新测试'"
|
||||
@click.stop="emit('test')"
|
||||
>
|
||||
<IconLoad v-if="testing" class="spin" />
|
||||
<template v-else-if="delay !== null">{{ delay }} ms</template>
|
||||
<IconFlash v-else />
|
||||
</button>
|
||||
<span v-else class="delay is-blank"></span>
|
||||
|
||||
<IconLoad v-if="pending" class="spin pend" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
--tone: var(--c-accent);
|
||||
--tone-soft: var(--c-accent-soft);
|
||||
--tone-text: var(--c-accent-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface-sunken);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.card.tone-chain {
|
||||
--tone: var(--c-chain);
|
||||
--tone-soft: var(--c-chain-soft);
|
||||
--tone-text: var(--c-chain-text);
|
||||
}
|
||||
|
||||
.card.is-on {
|
||||
border-color: var(--tone);
|
||||
background: var(--tone-soft);
|
||||
}
|
||||
|
||||
.card:not(.is-off):hover {
|
||||
border-color: var(--tone);
|
||||
}
|
||||
|
||||
.card.is-off {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.card.is-pending {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* 草稿态:虚线区分于实线的现实。将移除的再加删除线,和「只是禁用」分开 */
|
||||
.card.is-draft,
|
||||
.card.is-removed {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.card.is-removed .name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* 当前选中的线路即将被移除:危险色警示,这是草稿里最该被看见的一张卡 */
|
||||
.card.is-removed.is-on {
|
||||
border-color: var(--c-danger);
|
||||
background: var(--c-danger-soft);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card.is-removed.is-on .name {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.hit {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
min-width: 0;
|
||||
padding: var(--s-3) var(--s-3) var(--s-1);
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 没有 foot 时主按钮补回它的全部高度(延迟行 20px + 它的下边距),
|
||||
草稿卡和真实卡等高,网格不跳动,而且整张卡都是点击区 */
|
||||
.card.no-foot .hit {
|
||||
padding-bottom: calc(var(--s-1) + 20px + var(--s-2));
|
||||
}
|
||||
|
||||
.hit:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card.is-on .name {
|
||||
color: var(--tone-text);
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--c-text-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-2);
|
||||
padding: 0 var(--s-3) var(--s-2);
|
||||
}
|
||||
|
||||
/* 空白处的点击是选择,光标也照选择的样子给 */
|
||||
.card:not(.is-off) .foot {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
padding: 0 var(--s-1);
|
||||
margin-left: calc(-1 * var(--s-1));
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
font-family: var(--f-mono);
|
||||
font-size: var(--t-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.delay:hover:not(:disabled) {
|
||||
background: var(--c-surface);
|
||||
color: var(--tone-text);
|
||||
}
|
||||
|
||||
.delay:disabled {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.delay.is-blank {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.d-ok {
|
||||
color: var(--c-ok);
|
||||
}
|
||||
.d-warn {
|
||||
color: var(--c-warn);
|
||||
}
|
||||
.d-bad {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.pend {
|
||||
color: var(--c-text-faint);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间,末尾是调顺序的两个箭头。
|
||||
*
|
||||
* 只管显示、选中和「往哪边挪一位」,不碰任何数据。
|
||||
*/
|
||||
import IconUp from '~icons/ri/arrow-up-line'
|
||||
import IconDown from '~icons/ri/arrow-down-line'
|
||||
|
||||
defineProps<{
|
||||
/** 一行要显示的东西,由页面按配置和运行时数据拼好 */
|
||||
rows: Array<{
|
||||
name: string
|
||||
/** 当前有多少条线路。null 表示内核没给数据(没连接,或这个订阅还没拉取过) */
|
||||
lines: number | null
|
||||
/** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */
|
||||
updated: Date | null
|
||||
}>
|
||||
/** 配置正在保存。调顺序要写配置文件,保存期间不能再点 */
|
||||
saving: boolean
|
||||
}>()
|
||||
const selected = defineModel<string | null>('selected', { required: true })
|
||||
|
||||
/** 把这个订阅在列表里挪一位。写配置文件,由页面走保存链路。 */
|
||||
const emit = defineEmits<{ move: [name: string, delta: -1 | 1] }>()
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
function formatUpdated(at: Date): string {
|
||||
return `${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol class="rows">
|
||||
<li
|
||||
v-for="(row, i) in rows"
|
||||
:key="row.name"
|
||||
:class="['row', selected === row.name && 'is-on']"
|
||||
>
|
||||
<button type="button" class="line" @click="selected = row.name">
|
||||
<span class="name">{{ row.name }}</span>
|
||||
<span class="lines mono">
|
||||
<template v-if="row.lines !== null">{{ row.lines }} 条</template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
<span class="updated mono">{{ row.updated ? formatUpdated(row.updated) : '—' }}</span>
|
||||
</button>
|
||||
<span class="acts">
|
||||
<button
|
||||
type="button"
|
||||
title="上移"
|
||||
:disabled="saving || i === 0"
|
||||
@click="emit('move', row.name, -1)"
|
||||
>
|
||||
<IconUp />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="下移"
|
||||
:disabled="saving || i === rows.length - 1"
|
||||
@click="emit('move', row.name, 1)"
|
||||
>
|
||||
<IconDown />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rows li + li {
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: var(--s-3);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.row.is-on {
|
||||
background: var(--c-accent-soft);
|
||||
}
|
||||
|
||||
.line {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 4rem 6.5rem;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-2) var(--s-3) var(--s-4);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lines,
|
||||
.updated {
|
||||
text-align: right;
|
||||
font-size: var(--t-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
/* 平时不显示:调顺序不是常用动作,每行常驻两个箭头只是噪声。
|
||||
隐藏用 opacity 而不是 display,位置一直占着,鼠标划过时右边一列才不会跳 */
|
||||
.acts {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
gap: 1px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.row:hover .acts,
|
||||
.acts:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.acts button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.acts button:hover:not(:disabled) {
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.acts button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 单选下拉,替代原生 <select>。
|
||||
*
|
||||
* 换成自绘只为一件事:原生控件没有「展开中」这个可读状态,右侧箭头做不了
|
||||
* 开合动画,弹层样式也管不到。交互仿 SourcePicker(同一族控件):点开列出
|
||||
* 全部选项、勾标当前选中;上下键移动、Enter 选中、Esc 只收弹层不关对话框。
|
||||
*
|
||||
* 背景色用 --select-bg 变量留了口子:对话框里的控件要比对话框底色沉一档
|
||||
* (--c-bg),页面上的控件要比页面底色浮一档(--c-surface),一个值满足不了。
|
||||
*/
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import IconCheck from '~icons/ri/check-line'
|
||||
import IconChevron from '~icons/ri/arrow-down-s-line'
|
||||
|
||||
export interface SelectItem {
|
||||
value: string
|
||||
/** 界面上显示的字,缺省用 value 原文 */
|
||||
label?: string
|
||||
/** 分组标题。相邻同组的选项归在一个标题下 */
|
||||
group?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
options: SelectItem[]
|
||||
/** 当前值不在选项里(含空串)时显示的占位文案 */
|
||||
placeholder?: string
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const chosen = defineModel<string>({ default: '' })
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
/** 往上弹。下方视口不够放弹层、上方够时翻转,不把弹层怼出屏幕 */
|
||||
const openUp = ref(false)
|
||||
const active = ref(0)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
|
||||
const current = computed(() => props.options.find((o) => o.value === chosen.value) ?? null)
|
||||
|
||||
const MENU_ROOM = 220
|
||||
|
||||
/**
|
||||
* 弹层是 position: fixed,按视口摆放。absolute 的弹层会计入祖先滚动容器的
|
||||
* 可滚动区域——在对话框里打开就把对话框撑出滚动条。fixed 逃出所有 overflow
|
||||
* 裁剪,又因为还在 dialog 的 DOM 子树里,照样画在 top layer 之上。
|
||||
* 代价是位置不跟随滚动,所以下面在滚动和缩放时直接收起。
|
||||
*/
|
||||
function place() {
|
||||
const rect = root.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const up = window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
|
||||
openUp.value = up
|
||||
menuStyle.value = {
|
||||
left: `${String(rect.left)}px`,
|
||||
minWidth: `${String(rect.width)}px`,
|
||||
...(up
|
||||
? { bottom: `${String(window.innerHeight - rect.top + 4)}px` }
|
||||
: { top: `${String(rect.bottom + 4)}px` }),
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
active.value = Math.max(
|
||||
0,
|
||||
props.options.findIndex((o) => o.value === chosen.value),
|
||||
)
|
||||
place()
|
||||
open.value = true
|
||||
}
|
||||
|
||||
/** 页面或对话框一滚动,锚点就不准了,直接收起。弹层内部的滚动不算 */
|
||||
function closeOnMove(event?: Event) {
|
||||
if (event?.target instanceof Node && menuEl.value?.contains(event.target)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
window.addEventListener('scroll', closeOnMove, true)
|
||||
window.addEventListener('resize', closeOnMove)
|
||||
} else {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
})
|
||||
|
||||
function pick(value: string) {
|
||||
chosen.value = value
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
if (!open.value) {
|
||||
toggleOpen()
|
||||
return
|
||||
}
|
||||
const count = props.options.length
|
||||
if (count === 0) return
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
active.value = (active.value + delta + count) % count
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (!open.value) {
|
||||
toggleOpen()
|
||||
return
|
||||
}
|
||||
const item = props.options[active.value]
|
||||
if (item) pick(item.value)
|
||||
} else if (event.key === 'Escape' && open.value) {
|
||||
// 对话框里按 Esc 只收弹层,不能顺手把整个对话框关了
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 焦点离开整块时收起。新焦点还在里面就不收,否则点选项会先把自己关掉。 */
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && root.value?.contains(next)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
/** 这个下标前面要不要画组标题:是组里第一项才画。 */
|
||||
function groupAt(index: number): string | null {
|
||||
const group = props.options[index]?.group
|
||||
if (!group) return null
|
||||
return props.options[index - 1]?.group === group ? null : group
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="select" @focusout="onFocusOut">
|
||||
<button
|
||||
type="button"
|
||||
class="face"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:aria-label="ariaLabel"
|
||||
@click="toggleOpen"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span :class="['face-text', !current && 'is-empty']">
|
||||
{{ current ? (current.label ?? current.value) : (placeholder ?? '请选择') }}
|
||||
</span>
|
||||
<IconChevron :class="['chev', open && 'is-open']" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<Transition name="menu">
|
||||
<ul v-if="open" ref="menuEl" :class="['menu', openUp && 'is-up']" :style="menuStyle" role="listbox">
|
||||
<li v-if="options.length === 0" class="none">没有可选项</li>
|
||||
<template v-for="(item, i) in options" :key="item.value">
|
||||
<li v-if="groupAt(i)" class="group" role="presentation">{{ groupAt(i) }}</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="item.value === chosen"
|
||||
:class="['opt', item.value === chosen && 'is-on', i === active && 'is-active']"
|
||||
@mousemove="active = i"
|
||||
@click="pick(item.value)"
|
||||
>
|
||||
<span class="mark"><IconCheck v-if="item.value === chosen" /></span>
|
||||
{{ item.label ?? item.value }}
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 宽度不在这里定:块级元素在表单列里自然撑满,行内布局由调用方给定宽度。
|
||||
写死 width: 100% 会和调用方的宽度规则打架,两条同权重规则谁赢看加载顺序 */
|
||||
.select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.face {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--select-bg, var(--c-bg));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 和文本输入一致:焦点态是描边变色,不叠外圈 */
|
||||
.face:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
.face-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.face-text.is-empty {
|
||||
color: var(--c-text-faint);
|
||||
}
|
||||
|
||||
.chev {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--c-text-faint);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chev.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 位置(left/top/bottom/minWidth)由 place() 以内联样式给出 */
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
/* 弹层可以比控件宽(按最长选项撑开),但不能宽出屏幕 */
|
||||
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
/* 细杆加透明轨道,别让弹层里出现一根系统灰滚动条 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s-1);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.group {
|
||||
padding: var(--s-2) var(--s-3) var(--s-1);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-size: var(--t-sm);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opt.is-active {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.opt.is-on {
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.mark {
|
||||
display: flex;
|
||||
flex: none;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.none {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.menu-enter-active,
|
||||
.menu-leave-active {
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.menu-enter-from,
|
||||
.menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.menu.is-up.menu-enter-from,
|
||||
.menu.is-up.menu-leave-to {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 订阅多选。已选的是输入框里的一排标签,输入文字过滤下拉列表,点选项在
|
||||
* 选与不选之间切换,列表保持打开,方便一口气挑好几个。
|
||||
*
|
||||
* 交互是多选组合框的通行形态(Ant Design、Element Plus 的多选 Select 都是这一套):
|
||||
* 标签收在框内、下拉列出全部选项并给已选的打勾、点击不收起。订阅只有一两个时
|
||||
* 平铺标签更直接,但数量一多平铺就是一面墙,这个形态两头都撑得住。
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import IconCheck from '~icons/ri/check-line'
|
||||
import IconRemove from '~icons/ri/close-line'
|
||||
|
||||
const props = defineProps<{ options: string[] }>()
|
||||
const chosen = defineModel<string[]>({ required: true })
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
const openUp = ref(false)
|
||||
const query = ref('')
|
||||
/** 键盘上下键在过滤结果里走到第几项 */
|
||||
const active = ref(0)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
|
||||
const filtered = computed(() =>
|
||||
props.options.filter((name) => name.toLowerCase().includes(query.value.trim().toLowerCase())),
|
||||
)
|
||||
|
||||
watch(query, () => {
|
||||
active.value = 0
|
||||
open.value = true
|
||||
})
|
||||
|
||||
const MENU_ROOM = 220
|
||||
|
||||
/**
|
||||
* 弹层是 position: fixed,按视口摆放,理由同 SelectBox:absolute 会计入
|
||||
* 祖先滚动容器的可滚动区域,把页面或对话框撑出滚动条。
|
||||
* 位置不跟随滚动,所以滚动和缩放时直接收起。
|
||||
*/
|
||||
function place() {
|
||||
const rect = root.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const up = window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
|
||||
openUp.value = up
|
||||
menuStyle.value = {
|
||||
left: `${String(rect.left)}px`,
|
||||
minWidth: `${String(rect.width)}px`,
|
||||
...(up
|
||||
? { bottom: `${String(window.innerHeight - rect.top + 4)}px` }
|
||||
: { top: `${String(rect.bottom + 4)}px` }),
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面一滚动锚点就不准了,直接收起。弹层内部的滚动不算 */
|
||||
function closeOnMove(event?: Event) {
|
||||
if (event?.target instanceof Node && menuEl.value?.contains(event.target)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
place()
|
||||
window.addEventListener('scroll', closeOnMove, true)
|
||||
window.addEventListener('resize', closeOnMove)
|
||||
} else {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', closeOnMove, true)
|
||||
window.removeEventListener('resize', closeOnMove)
|
||||
})
|
||||
|
||||
function toggle(name: string) {
|
||||
chosen.value = chosen.value.includes(name)
|
||||
? chosen.value.filter((item) => item !== name)
|
||||
: [...chosen.value, name]
|
||||
// 点完一项接着挑下一项,不用再点回输入框
|
||||
input.value?.focus()
|
||||
// 标签增减会改变输入框高度,锚点变了要重新摆
|
||||
void nextTick(place)
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
chosen.value = chosen.value.filter((item) => item !== name)
|
||||
if (open.value) void nextTick(place)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
open.value = true
|
||||
const count = filtered.value.length
|
||||
if (count === 0) return
|
||||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||||
active.value = (active.value + delta + count) % count
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const name = filtered.value[active.value]
|
||||
if (open.value && name) toggle(name)
|
||||
} else if (event.key === 'Escape') {
|
||||
open.value = false
|
||||
} else if (event.key === 'Backspace' && query.value === '') {
|
||||
const last = chosen.value.at(-1)
|
||||
if (last) remove(last)
|
||||
}
|
||||
}
|
||||
|
||||
/** 焦点离开整块时收起。判断新焦点还在不在里面,否则点列表里的选项会先把自己关掉。 */
|
||||
function onFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget
|
||||
if (next instanceof Node && root.value?.contains(next)) return
|
||||
open.value = false
|
||||
query.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="picker" @focusout="onFocusOut">
|
||||
<div class="box" @click="input?.focus()">
|
||||
<span v-for="name in chosen" :key="name" class="tag">
|
||||
{{ name }}
|
||||
<button type="button" :title="`移除 ${name}`" @click.stop="remove(name)">
|
||||
<IconRemove />
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="query"
|
||||
:placeholder="chosen.length === 0 ? '选择订阅' : ''"
|
||||
:aria-expanded="open"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
@focus="open = true"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition name="menu">
|
||||
<ul
|
||||
v-if="open"
|
||||
ref="menuEl"
|
||||
:class="['menu', openUp && 'is-up']"
|
||||
:style="menuStyle"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
>
|
||||
<li v-if="options.length === 0" class="none">没有可选的订阅</li>
|
||||
<li v-else-if="filtered.length === 0" class="none">没有匹配的订阅</li>
|
||||
<li v-for="(name, i) in filtered" :key="name">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="chosen.includes(name)"
|
||||
:class="['opt', chosen.includes(name) && 'is-on', i === active && 'is-active']"
|
||||
@mousemove="active = i"
|
||||
@click="toggle(name)"
|
||||
>
|
||||
<span class="mark"><IconCheck v-if="chosen.includes(name)" /></span>
|
||||
{{ name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
min-height: 34px;
|
||||
padding: 3px var(--s-2);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
cursor: text;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.box:focus-within {
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
height: 26px;
|
||||
padding: 0 var(--s-1) 0 var(--s-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface-sunken);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.tag button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag button:hover {
|
||||
background: var(--c-surface);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.box input {
|
||||
flex: 1;
|
||||
min-width: 6em;
|
||||
height: 26px;
|
||||
padding: 0 var(--s-1);
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
/* 浮在上面,不能把下面几行顶下去。位置由 place() 以内联样式给出 */
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
/* 细杆加透明轨道,别让弹层里出现一根系统灰滚动条 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s-1);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-size: var(--t-sm);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opt.is-active {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.opt.is-on {
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
/* 勾的位置固定占着,选没选名字都对齐 */
|
||||
.mark {
|
||||
display: flex;
|
||||
flex: none;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.none {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.menu-enter-active,
|
||||
.menu-leave-active {
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.menu-enter-from,
|
||||
.menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.menu.is-up.menu-enter-from,
|
||||
.menu.is-up.menu-leave-to {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 轻提示的渲染宿主,挂在外壳根部。进场从右滑入,退场上移淡出。
|
||||
* 点一下:普通提示立刻消掉;错误提示是要拿去粘贴求助的,点击复制而不是关闭,
|
||||
* 消失交给 toast.ts 的定时器。内容来自 web/toast.ts 的单例队列。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { dismiss, toasts } from '../toast'
|
||||
import type { Toast } from '../toast'
|
||||
import IconOk from '~icons/ri/checkbox-circle-line'
|
||||
import IconErr from '~icons/ri/error-warning-line'
|
||||
import IconInfo from '~icons/ri/information-line'
|
||||
|
||||
/** 已复制的错误提示:hint 原地换成「已复制到剪贴板」作为反馈。 */
|
||||
const copied = ref(new Set<number>())
|
||||
|
||||
async function onClick(t: Toast) {
|
||||
if (t.kind !== 'error') {
|
||||
dismiss(t.id)
|
||||
return
|
||||
}
|
||||
const ok = await copyText([t.message, t.hint].filter(Boolean).join('\n'))
|
||||
if (ok) copied.value = new Set(copied.value).add(t.id)
|
||||
}
|
||||
|
||||
/** 经 http 从局域网访问时没有 navigator.clipboard(非安全上下文),退回 execCommand。 */
|
||||
async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
const area = document.createElement('textarea')
|
||||
area.value = text
|
||||
area.style.position = 'fixed'
|
||||
area.style.opacity = '0'
|
||||
document.body.appendChild(area)
|
||||
area.select()
|
||||
const ok = document.execCommand('copy')
|
||||
area.remove()
|
||||
return ok
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="host" aria-live="polite">
|
||||
<TransitionGroup name="tst">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
:class="['tst', t.kind === 'error' && 'is-error']"
|
||||
:title="t.kind === 'error' ? '点击复制错误信息' : undefined"
|
||||
@click="onClick(t)"
|
||||
>
|
||||
<IconErr v-if="t.kind === 'error'" class="tico" />
|
||||
<IconInfo v-else-if="t.kind === 'info'" class="tico is-info" />
|
||||
<IconOk v-else class="tico" />
|
||||
<div class="tbody">
|
||||
<strong class="tmsg">{{ t.message }}</strong>
|
||||
<span v-if="copied.has(t.id)" class="thint">已复制到剪贴板</span>
|
||||
<span v-else-if="t.hint" class="thint">{{ t.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.host {
|
||||
position: fixed;
|
||||
top: var(--s-4);
|
||||
right: var(--s-4);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--s-2);
|
||||
/* 宿主自己不挡点击,字条才挡 */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tst {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-3);
|
||||
min-width: 240px;
|
||||
max-width: 380px;
|
||||
padding: var(--s-3) var(--s-4);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 0.16);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tst.is-error {
|
||||
border-color: color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
||||
}
|
||||
|
||||
.tico {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 1px;
|
||||
color: var(--c-ok);
|
||||
}
|
||||
|
||||
.is-error .tico {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.tico.is-info {
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.tbody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tmsg {
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.thint {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--c-text-soft);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 进场:从右滑入;退场:上移淡出;队列压缩时其余字条平滑归位 */
|
||||
.tst-enter-active {
|
||||
transition:
|
||||
transform 0.28s cubic-bezier(0.21, 0.8, 0.35, 1),
|
||||
opacity 0.28s ease;
|
||||
}
|
||||
.tst-enter-from {
|
||||
transform: translateX(120%);
|
||||
opacity: 0;
|
||||
}
|
||||
.tst-leave-active {
|
||||
transition:
|
||||
transform 0.4s ease,
|
||||
opacity 0.4s ease;
|
||||
/* 退场中脱离布局,后面的字条立刻开始归位 */
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
.tst-leave-to {
|
||||
transform: translateY(-14px);
|
||||
opacity: 0;
|
||||
}
|
||||
.tst-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { firstPort, kernelPorts, modeLabel } from './dashboard.ts'
|
||||
|
||||
describe('运行模式', () => {
|
||||
it('三个已知模式有中文名', () => {
|
||||
expect(modeLabel('rule')).toBe('规则')
|
||||
expect(modeLabel('global')).toBe('全局')
|
||||
expect(modeLabel('direct')).toBe('直连')
|
||||
})
|
||||
|
||||
it('没见过的值原样显示,缺省是破折号', () => {
|
||||
expect(modeLabel('script')).toBe('script')
|
||||
expect(modeLabel(undefined)).toBe('—')
|
||||
expect(modeLabel('')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('代理端口', () => {
|
||||
it('挑出大于 0 的端口,按固定顺序', () => {
|
||||
expect(kernelPorts({ 'mixed-port': 7890, port: 7891, 'socks-port': 0 })).toEqual([
|
||||
{ name: 'port', port: 7891 },
|
||||
{ name: 'mixed-port', port: 7890 },
|
||||
])
|
||||
})
|
||||
|
||||
it('0、缺省和非数字都不算启用', () => {
|
||||
expect(kernelPorts({ port: 0, 'socks-port': 'abc' })).toEqual([])
|
||||
})
|
||||
|
||||
it('字符串端口号也认得', () => {
|
||||
expect(kernelPorts({ port: '7890' })).toEqual([{ name: 'port', port: 7890 }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('探测端口', () => {
|
||||
it('单个端口直接用', () => {
|
||||
expect(firstPort('7890')).toBe(7890)
|
||||
})
|
||||
|
||||
it('多端口和区间取第一个数', () => {
|
||||
expect(firstPort('200,302')).toBe(200)
|
||||
expect(firstPort('401-429')).toBe(401)
|
||||
})
|
||||
|
||||
it('解析不出或越界返回 null', () => {
|
||||
expect(firstPort('')).toBeNull()
|
||||
expect(firstPort('abc')).toBeNull()
|
||||
expect(firstPort('70000')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 仪表盘页的数据整理。
|
||||
*
|
||||
* 「拿到内核数据之后怎么摆」的纯逻辑,放在这里而不是 DashboardPage.vue 的
|
||||
* computed 里,才能被 bun test 直接测到。流量窗口和每路吞吐的聚合在
|
||||
* shared/telemetry.ts——那些是采集层的事,跨页面共用。
|
||||
*/
|
||||
|
||||
/** 内核运行模式在界面上怎么念。不在表里的原样显示,不猜它的意思。 */
|
||||
export function modeLabel(mode: unknown): string {
|
||||
switch (mode) {
|
||||
case 'rule':
|
||||
return '规则'
|
||||
case 'global':
|
||||
return '全局'
|
||||
case 'direct':
|
||||
return '直连'
|
||||
default:
|
||||
return typeof mode === 'string' && mode ? mode : '—'
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个监听中的代理端口。name 用配置键原词,和路由页legacy 入站的命名一致。 */
|
||||
export interface KernelPort {
|
||||
name: string
|
||||
port: number
|
||||
}
|
||||
|
||||
const PORT_KEYS = ['port', 'socks-port', 'mixed-port', 'redir-port', 'tproxy-port'] as const
|
||||
|
||||
/** 从内核 /configs 的运行时设置里挑出正在监听的代理端口。0 和缺省都表示没启用。 */
|
||||
export function kernelPorts(configs: Record<string, unknown>): KernelPort[] {
|
||||
const result: KernelPort[] = []
|
||||
for (const key of PORT_KEYS) {
|
||||
const raw = configs[key]
|
||||
const port = typeof raw === 'number' ? raw : Number(raw)
|
||||
if (Number.isFinite(port) && port > 0) result.push({ name: key, port })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 入站声明的端口在探测公网 IP 时用哪个数。
|
||||
* 新式入站的端口可以写 "200,302" 或 "401-429",探测取第一个数字;解析不出返回 null。
|
||||
*/
|
||||
export function firstPort(port: string): number | null {
|
||||
const match = /\d+/.exec(port)
|
||||
if (!match) return null
|
||||
const value = Number(match[0])
|
||||
return value >= 1 && value <= 65535 ? value : null
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { ApiError } from './api'
|
||||
import { describeTestFailure } from './delay-test'
|
||||
|
||||
const LINE = '[vless]Gomami.SIN Secret'
|
||||
|
||||
describe('延迟测试失败时告诉用户什么', () => {
|
||||
it('超时说明是几秒内没完成', () => {
|
||||
// 只说「超时」,用户不知道是等了 1 秒还是 60 秒,也就不知道该不该重试
|
||||
expect(describeTestFailure(LINE, new ApiError(504, {}), 5000)).toBe(
|
||||
`${LINE} 延迟测试超时,5 秒内没有取到测试地址`,
|
||||
)
|
||||
})
|
||||
|
||||
it('找不到这个名字时指出该从订阅进去测', () => {
|
||||
// 订阅线路不在内核的全局出站表里,这是本项目最容易踩的一条约束
|
||||
expect(describeTestFailure(LINE, new ApiError(404, { message: 'Resource not found' }))).toContain(
|
||||
'不在内核的出站表里',
|
||||
)
|
||||
})
|
||||
|
||||
it('连接失败且内核没给具体原因时,给出能指导排查的话', () => {
|
||||
const generic = new ApiError(503, { message: 'An error occurred in the delay test' })
|
||||
expect(describeTestFailure(LINE, generic)).toBe(
|
||||
`${LINE} 无法建立连接,线路可能已失效,或本机到该服务器的网络不通`,
|
||||
)
|
||||
})
|
||||
|
||||
it('内核给了具体原因就原样带上', () => {
|
||||
const specific = new ApiError(503, { message: 'dial tcp: i/o timeout' })
|
||||
expect(describeTestFailure(LINE, specific)).toBe(`${LINE} 无法建立连接:dial tcp: i/o timeout`)
|
||||
})
|
||||
|
||||
it('没见过的状态码带上状态码和内核原话', () => {
|
||||
expect(describeTestFailure(LINE, new ApiError(500, { message: 'boom' }))).toBe(
|
||||
`${LINE} 延迟测试失败(HTTP 500):boom`,
|
||||
)
|
||||
})
|
||||
|
||||
it('状态码没见过且内核没说话时不留一个空冒号', () => {
|
||||
expect(describeTestFailure(LINE, new ApiError(500, {}))).toBe(
|
||||
`${LINE} 延迟测试失败(HTTP 500)`,
|
||||
)
|
||||
})
|
||||
|
||||
it('不是接口错误时也要说出是什么', () => {
|
||||
// 网络断了、代码抛了异常,都会走到这里,不能显示成 [object Object]
|
||||
expect(describeTestFailure(LINE, new TypeError('Failed to fetch'))).toBe(
|
||||
`${LINE} 延迟测试失败:TypeError: Failed to fetch`,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 线路延迟测试。
|
||||
*
|
||||
* 单独成一个模块,是因为「测失败了该跟用户说什么」有六个分支,而它原本写在组件里,
|
||||
* 一条测试都加不了。这些话是用户唯一能看到的诊断信息,值得测。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { ApiError, api } from './api'
|
||||
|
||||
/** 超时,毫秒。文案里会用到它,所以不能让接口和文案各写一个数。 */
|
||||
export const TEST_TIMEOUT_MS = 5000
|
||||
|
||||
/**
|
||||
* 把内核的延迟测试结果翻译成一句说明原因的话。
|
||||
*
|
||||
* 内核对这件事的回答是精确的(内核源码 hub/route/proxies.go:122-145):
|
||||
* 504 在给定超时内没有完成
|
||||
* 503 连接建立失败,或者测得的延迟为 0
|
||||
* 404 这个名字不在内核的全局出站表里
|
||||
* 笼统说一句「测不通」会把它们混在一起,而这三种要查的地方完全不一样。
|
||||
*/
|
||||
export function describeTestFailure(
|
||||
name: string,
|
||||
error: unknown,
|
||||
timeoutMs = TEST_TIMEOUT_MS,
|
||||
): string {
|
||||
if (!(error instanceof ApiError)) return `${name} 延迟测试失败:${String(error)}`
|
||||
|
||||
if (error.status === 504) {
|
||||
return `${name} 延迟测试超时,${String(timeoutMs / 1000)} 秒内没有取到测试地址`
|
||||
}
|
||||
|
||||
if (error.status === 404) {
|
||||
return `${name} 不在内核的出站表里,无法测试。订阅线路需要从所属订阅进去测。`
|
||||
}
|
||||
|
||||
const detail = String(error.payload.message ?? '')
|
||||
// 内核对连接失败给的是一句没有信息量的通用英文,替换成能指导排查的话
|
||||
const generic = detail === '' || detail === 'An error occurred in the delay test'
|
||||
|
||||
if (error.status === 503) {
|
||||
return generic
|
||||
? `${name} 无法建立连接,线路可能已失效,或本机到该服务器的网络不通`
|
||||
: `${name} 无法建立连接:${detail}`
|
||||
}
|
||||
|
||||
return `${name} 延迟测试失败(HTTP ${String(error.status)})${generic ? '' : `:${detail}`}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理「哪几条线路正在测」。
|
||||
*
|
||||
* 测延迟是纯读操作,不影响配置也不影响别的线路,所以它只标记自己那一条忙碌,
|
||||
* 不禁用界面上的任何其它东西。
|
||||
*/
|
||||
export function useDelayTest(onFinished: () => void, onFailure: (message: string) => void) {
|
||||
const testing = ref<string[]>([])
|
||||
|
||||
async function test(name: string, provider?: string) {
|
||||
if (testing.value.includes(name)) return
|
||||
testing.value = [...testing.value, name]
|
||||
try {
|
||||
await api.testLine(name, provider, TEST_TIMEOUT_MS)
|
||||
onFinished()
|
||||
} catch (error) {
|
||||
// 测不通是关于线路的结论,不是 Patchbay 出了故障
|
||||
onFailure(describeTestFailure(name, error))
|
||||
} finally {
|
||||
testing.value = testing.value.filter((n) => n !== name)
|
||||
}
|
||||
}
|
||||
|
||||
return { testing, test }
|
||||
}
|
||||
@@ -4,6 +4,15 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<!-- 标签页图标只声明 .ico:它里面有 16/32/48/64 四个尺寸,是照着小尺寸调过的。
|
||||
再声明一个大 PNG 的话,浏览器会按显示密度在两者之间挑,而这套素材的 .ico 是
|
||||
深底浅图、PNG 是白底黑图,挑到哪个不一定,同一个标签页会换样子。 -->
|
||||
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48 64x64" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<!-- 移动端浏览器地址栏的底色,跟着系统主题走,和 tokens.css 里的 --c-bg 一致 -->
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fbfbfc" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#16181c" />
|
||||
<title>Patchbay</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 免责声明的文案。设置页的子页和登录页的弹层共用同一份,不出现两个版本。
|
||||
*/
|
||||
export const DISCLAIMER_TITLE = '免责声明'
|
||||
|
||||
export const DISCLAIMER = [
|
||||
'Patchbay 是编辑 mihomo 配置文件的本地网页工具,按现状提供,不提供任何形式的担保。因配置错误、软件缺陷或网络中断造成的服务不可用、数据丢失,作者不承担责任;重要改动前请自行留存配置文件副本。',
|
||||
'代理技术本身是中性的。使用者应遵守所在国家和地区的法律法规,因使用本工具产生的一切后果由使用者自行承担。',
|
||||
'配置文件与备份中可能包含密钥、订阅地址等敏感信息,访问令牌等同于密码,请妥善保管,不要在公开场合分享。',
|
||||
'继续使用 Patchbay 即视为已阅读并同意以上条款。',
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 内核日志的常驻收集。
|
||||
*
|
||||
* 住在模块级单例里而不是请求页组件里,是因为日志要跨页面留存——收集跟着组件
|
||||
* 走的话,切走一次列表就清空一次。外壳启动时点火,之后跟着 kernel.online 起落。
|
||||
*
|
||||
* 节奏和缓冲:收到的行先进 pending,每 200ms 一批刷进 rows——日志密集时逐条
|
||||
* 触发响应式更新会把界面拖死。暂停就是不刷,缓冲照收(也设上限),恢复时补上。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { kernel } from './runtime'
|
||||
import { LOG_CAP, pushCapped, streamLogs } from './logs'
|
||||
import type { LogLevel, LogLine } from './logs'
|
||||
|
||||
export interface LogRow extends LogLine {
|
||||
id: number
|
||||
/** 收到那一刻的时间。日志行本身不带时间戳 */
|
||||
time: string
|
||||
}
|
||||
|
||||
/** 日志级别。改它会断开重连:内核的级别是建立连接时定的,中途改不了。 */
|
||||
export const level = ref<LogLevel>('info')
|
||||
export const paused = ref(false)
|
||||
export const rows = ref<LogRow[]>([])
|
||||
export const failure = ref('')
|
||||
/** 内核那边把流断了(比如重启)。和主动中止区分开,这个要告诉用户 */
|
||||
export const ended = ref(false)
|
||||
/** 每刷进一批加一。rows 是原地增改,组件要跟随滚动只能看这个信号 */
|
||||
export const revision = ref(0)
|
||||
|
||||
let pending: LogRow[] = []
|
||||
let nextId = 0
|
||||
let aborter: AbortController | null = null
|
||||
let retryTimer = 0
|
||||
let started = false
|
||||
|
||||
const clock = (d: Date): string =>
|
||||
[d.getHours(), d.getMinutes(), d.getSeconds()]
|
||||
.map((n) => String(n).padStart(2, '0'))
|
||||
.join(':')
|
||||
|
||||
function flush() {
|
||||
if (paused.value) {
|
||||
// 暂停时继续接收但不渲染。缓冲也设上限,暂停再久内存也不涨
|
||||
if (pending.length > LOG_CAP) pending.splice(0, pending.length - LOG_CAP)
|
||||
return
|
||||
}
|
||||
if (pending.length === 0) return
|
||||
pushCapped(rows.value, pending, LOG_CAP)
|
||||
pending = []
|
||||
revision.value += 1
|
||||
}
|
||||
|
||||
/** 只清界面上攒下的,内核那边的日志不受影响。 */
|
||||
export function clearLogs() {
|
||||
rows.value = []
|
||||
pending = []
|
||||
revision.value += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 断流不等于内核没了:配置重载会掐断流,而内核几秒内就回来、online 从未翻转。
|
||||
* 断了 5 秒后自动重连,失败会再次走到这里形成重试节奏;真离线时交给下面等
|
||||
* online 翻转的 watch。
|
||||
*/
|
||||
function scheduleRetry() {
|
||||
window.clearTimeout(retryTimer)
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (kernel.value.online) void pump()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async function pump() {
|
||||
// 切换级别或内核恢复在线都从这里重连。旧流先停,不然两条流一起往缓冲里倒
|
||||
aborter?.abort()
|
||||
window.clearTimeout(retryTimer)
|
||||
const controller = new AbortController()
|
||||
aborter = controller
|
||||
failure.value = ''
|
||||
ended.value = false
|
||||
try {
|
||||
for await (const line of streamLogs(level.value, controller.signal)) {
|
||||
pending.push({ ...line, id: nextId++, time: clock(new Date()) })
|
||||
}
|
||||
if (!controller.signal.aborted) {
|
||||
ended.value = true
|
||||
scheduleRetry()
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
failure.value = error instanceof Error ? error.message : String(error)
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
|
||||
/** 外壳启动时调一次。之后跟着内核在线状态和级别自己重连,应用关掉前不停。 */
|
||||
export function startLogStream(): void {
|
||||
if (started) return
|
||||
started = true
|
||||
// 刷新节拍常驻,缓冲空转一次是零开销;应用关掉前不需要清
|
||||
window.setInterval(flush, 200)
|
||||
if (kernel.value.online) void pump()
|
||||
|
||||
// 挂载时内核不在线就不连;等它上线再开始。反方向(流到一半内核掉了)
|
||||
// 由流自己报错或结束来呈现
|
||||
watch(
|
||||
() => kernel.value.online,
|
||||
(online) => {
|
||||
if (online) void pump()
|
||||
},
|
||||
)
|
||||
|
||||
watch(level, () => {
|
||||
if (kernel.value.online) void pump()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterAll, afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { LOG_CAP, parseLogLine, pushCapped, streamLogs } from './logs.ts'
|
||||
import { LogStreamError } from './logs.ts'
|
||||
|
||||
describe('解析日志行', () => {
|
||||
it('形状对的行解出级别和内容', () => {
|
||||
expect(parseLogLine('{"type":"info","payload":"[TCP] dial example.com"}')).toEqual({
|
||||
type: 'info',
|
||||
payload: '[TCP] dial example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('不是 JSON 的脏行返回 null 而不是抛出去', () => {
|
||||
expect(parseLogLine('not json at all')).toBeNull()
|
||||
expect(parseLogLine('{"type":"info","payl')).toBeNull()
|
||||
})
|
||||
|
||||
it('是 JSON 但形状不对的也跳过', () => {
|
||||
expect(parseLogLine('null')).toBeNull()
|
||||
expect(parseLogLine('[1,2]')).toBeNull()
|
||||
expect(parseLogLine('{"type":"info"}')).toBeNull()
|
||||
expect(parseLogLine('{"type":"info","payload":42}')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('成批追加上限', () => {
|
||||
it('超出上限丢最旧的,留下最新的', () => {
|
||||
const list = [1, 2, 3]
|
||||
pushCapped(list, [4, 5, 6], 4)
|
||||
expect(list).toEqual([3, 4, 5, 6])
|
||||
})
|
||||
|
||||
it('一批比上限还大时只留下限条', () => {
|
||||
const list: number[] = []
|
||||
pushCapped(list, [1, 2, 3, 4, 5], 3)
|
||||
expect(list).toEqual([3, 4, 5])
|
||||
})
|
||||
})
|
||||
|
||||
function streamOf(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const realFetch = globalThis.fetch
|
||||
|
||||
// bun 的测试环境没有 localStorage,而 streamLogs 要从里面取令牌。
|
||||
// 桩只在本文件生效:bun test 是单进程,全局残留会影响之后跑的其它测试文件
|
||||
const hadStorage = typeof globalThis.localStorage !== 'undefined'
|
||||
if (!hadStorage) {
|
||||
;(globalThis as Record<string, unknown>).localStorage = {
|
||||
getItem: () => '',
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
if (!hadStorage) delete (globalThis as Record<string, unknown>).localStorage
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch
|
||||
})
|
||||
|
||||
describe('读日志流', () => {
|
||||
it('脏行被跳过,不会中断后面的行', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
streamOf([
|
||||
'{"type":"info","payload":"第一条"}\n',
|
||||
'这不是 JSON\n',
|
||||
'{"type":"warning","payload":"第',
|
||||
'二条"}\n',
|
||||
]),
|
||||
),
|
||||
),
|
||||
) as unknown as typeof fetch
|
||||
|
||||
const lines: string[] = []
|
||||
for await (const line of streamLogs('info', new AbortController().signal)) {
|
||||
lines.push(`${line.type}: ${line.payload}`)
|
||||
}
|
||||
expect(lines).toEqual(['info: 第一条', 'warning: 第二条'])
|
||||
})
|
||||
|
||||
it('接口报错时用服务端给的 message 抛出', async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ message: '内核未连接' }), { status: 502 }),
|
||||
),
|
||||
) as unknown as typeof fetch
|
||||
|
||||
const run = async () => {
|
||||
for await (const _ of streamLogs('info', new AbortController().signal)) {
|
||||
// 不该有产出
|
||||
}
|
||||
}
|
||||
await expect(run()).rejects.toThrow(new LogStreamError('内核未连接'))
|
||||
})
|
||||
|
||||
it('LOG_CAP 是个有限值,无限流不会把内存吃光', () => {
|
||||
expect(LOG_CAP).toBeGreaterThan(0)
|
||||
expect(LOG_CAP).toBeLessThanOrEqual(10000)
|
||||
})
|
||||
})
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 内核日志流。
|
||||
*
|
||||
* 接口是 `GET /api/kernel/logs?level=...`,一条永不结束的流,一行一个 JSON,
|
||||
* 形如 `{ "type": "info", "payload": "..." }`。所以不能走 api.ts 的 request()——
|
||||
* 那个会把整个响应读完,对流等于挂死。这里自己 fetch,按行读用 stream.ts。
|
||||
*
|
||||
* 解析和成批追加放在组件外面,是因为「脏行跳过」「超上限丢最旧的」这两条
|
||||
* 是验收里点名要测的行为,埋在 .vue 里测不到。
|
||||
*/
|
||||
import { getToken } from './api'
|
||||
import { readLines } from './stream'
|
||||
|
||||
/** 内核接受的日志级别。连接建立时定死,中途改不了,所以切换等于重连。 */
|
||||
export const LEVELS = ['debug', 'info', 'warning', 'error', 'silent'] as const
|
||||
export type LogLevel = (typeof LEVELS)[number]
|
||||
|
||||
/**
|
||||
* 页面上保留的最近条数。日志是无限流,不设上限内存会一直涨,
|
||||
* 而目标机器只有 1-2G。超出就丢最旧的。
|
||||
*/
|
||||
export const LOG_CAP = 1000
|
||||
|
||||
export interface LogLine {
|
||||
type: string
|
||||
payload: string
|
||||
}
|
||||
|
||||
/** 解析一行日志。脏行(不是 JSON、形状不对)返回 null,由调用方跳过,不能抛。 */
|
||||
export function parseLogLine(line: string): LogLine | null {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(line)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
|
||||
const { type, payload } = value as Record<string, unknown>
|
||||
if (typeof type !== 'string' || typeof payload !== 'string') return null
|
||||
return { type, payload }
|
||||
}
|
||||
|
||||
/** 把一批新条目追加进列表,超出上限从头部丢最旧的。 */
|
||||
export function pushCapped<T>(list: T[], items: T[], cap: number): void {
|
||||
list.push(...items)
|
||||
if (list.length > cap) list.splice(0, list.length - cap)
|
||||
}
|
||||
|
||||
export class LogStreamError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'LogStreamError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连上内核的日志流,逐条产出。停止的方式是中止 signal。
|
||||
*
|
||||
* 流正常结束(for await 走完)意味着内核那边断了连接,比如内核重启——
|
||||
* 调用方要把它和「主动中止」区分开,前者该告诉用户。
|
||||
*/
|
||||
export async function* streamLogs(
|
||||
level: LogLevel,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<LogLine> {
|
||||
const res = await fetch(`/api/kernel/logs?level=${level}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
// 失败时后端回的是一次性的 { message, hint? },不是流
|
||||
let message = `日志接口请求失败(HTTP ${String(res.status)})`
|
||||
try {
|
||||
const payload = JSON.parse(await res.text()) as Record<string, unknown>
|
||||
if (typeof payload.message === 'string') message = payload.message
|
||||
} catch {
|
||||
// 保留默认消息
|
||||
}
|
||||
throw new LogStreamError(message)
|
||||
}
|
||||
if (!res.body) throw new LogStreamError('日志接口没有返回数据流')
|
||||
|
||||
for await (const line of readLines(res.body)) {
|
||||
const entry = parseLogLine(line)
|
||||
if (entry) yield entry
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mergeMemberPreview } from './member-preview.ts'
|
||||
import type { ProxyInfo } from './api.ts'
|
||||
|
||||
const info = (name: string) => ({ name, type: 'Vmess' }) as ProxyInfo
|
||||
const noLookup = () => undefined
|
||||
|
||||
describe('成员预览', () => {
|
||||
it('草稿和现状一致时谁都不标', () => {
|
||||
const cards = mergeMemberPreview([{ name: 'A', info: info('A') }], ['A'], noLookup)
|
||||
expect(cards).toEqual([{ name: 'A', info: info('A'), removed: false, added: false }])
|
||||
})
|
||||
|
||||
it('不在保存后名单里的现有成员标为将移除', () => {
|
||||
const cards = mergeMemberPreview(
|
||||
[
|
||||
{ name: 'A', info: info('A') },
|
||||
{ name: 'B', info: info('B') },
|
||||
],
|
||||
['B'],
|
||||
noLookup,
|
||||
)
|
||||
expect(cards.map((c) => [c.name, c.removed])).toEqual([
|
||||
['A', true],
|
||||
['B', false],
|
||||
])
|
||||
})
|
||||
|
||||
it('新增的追加在末尾,线路信息从 lookup 取', () => {
|
||||
const cards = mergeMemberPreview([{ name: 'A', info: info('A') }], ['A', 'C'], info)
|
||||
expect(cards[1]).toEqual({ name: 'C', info: info('C'), removed: false, added: true })
|
||||
})
|
||||
|
||||
it('现有成员保持原序在前,新增按保存后名单的顺序在后', () => {
|
||||
// 顺序稳定才不会出现「改一个筛选,卡片全部跳位」
|
||||
const cards = mergeMemberPreview(
|
||||
[
|
||||
{ name: 'B', info: undefined },
|
||||
{ name: 'A', info: undefined },
|
||||
],
|
||||
['C', 'A', 'D'],
|
||||
noLookup,
|
||||
)
|
||||
expect(cards.map((c) => c.name)).toEqual(['B', 'A', 'C', 'D'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 成员卡片的「保存后会怎样」预览。
|
||||
*
|
||||
* 配置草稿和已保存内容不一致时,卡片区不再显示内核现状,而是显示按草稿保存后组里
|
||||
* 会有谁:留下的照常、将移除的标出来、新增的追加在后面。放在组件外面是为了让
|
||||
* bun test 够得着——这是纯集合运算,写进 computed 就没人测了。
|
||||
*/
|
||||
import type { ProxyInfo } from './api'
|
||||
|
||||
export interface MemberPreview {
|
||||
name: string
|
||||
info: ProxyInfo | undefined
|
||||
/** 现在是成员,保存后不再是 */
|
||||
removed: boolean
|
||||
/** 现在不是成员,保存后才加入 */
|
||||
added: boolean
|
||||
}
|
||||
|
||||
export function mergeMemberPreview(
|
||||
current: Array<{ name: string; info: ProxyInfo | undefined }>,
|
||||
future: string[],
|
||||
lookup: (name: string) => ProxyInfo | undefined,
|
||||
): MemberPreview[] {
|
||||
const futureSet = new Set(future)
|
||||
const currentNames = new Set(current.map((item) => item.name))
|
||||
return [
|
||||
...current.map((item) => ({
|
||||
name: item.name,
|
||||
info: item.info,
|
||||
removed: !futureSet.has(item.name),
|
||||
added: false,
|
||||
})),
|
||||
...future
|
||||
.filter((name) => !currentNames.has(name))
|
||||
.map((name) => ({ name, info: lookup(name), removed: false, added: true })),
|
||||
]
|
||||
}
|
||||
+9
-10
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* 左侧导航。
|
||||
*
|
||||
* 顺序按使用频率排:路由是这个工具存在的理由,放在最上面;仪表盘和连接是「看一眼」
|
||||
* 类的,中间;设置很少动,垫底。
|
||||
* 仪表盘打头(打开先看一眼状态),路由紧随其后(这个工具存在的理由),设置垫底。
|
||||
*/
|
||||
import IconRoute from '~icons/ri/route-line'
|
||||
import IconDashboard from '~icons/ri/dashboard-line'
|
||||
import IconConfig from '~icons/ri/file-list-3-line'
|
||||
import IconRequests from '~icons/ri/pulse-line'
|
||||
import IconRules from '~icons/ri/filter-3-line'
|
||||
import IconLogs from '~icons/ri/terminal-line'
|
||||
import IconConnections from '~icons/ri/links-line'
|
||||
import IconSettings from '~icons/ri/settings-3-line'
|
||||
import type { Component } from 'vue'
|
||||
@@ -16,15 +16,14 @@ export interface NavItem {
|
||||
key: string
|
||||
label: string
|
||||
icon: Component
|
||||
/** 还没做的页面标 true,导航上显示但点进去是占位 */
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
export const NAV: NavItem[] = [
|
||||
{ key: 'dashboard', label: '仪表盘', icon: IconDashboard },
|
||||
{ key: 'router', label: '路由', icon: IconRoute },
|
||||
{ key: 'dashboard', label: '仪表盘', icon: IconDashboard, pending: true },
|
||||
{ key: 'config', label: '配置', icon: IconConfig, pending: true },
|
||||
{ key: 'requests', label: '请求', icon: IconRequests, pending: true },
|
||||
{ key: 'connections', label: '连接', icon: IconConnections, pending: true },
|
||||
{ key: 'settings', label: '设置', icon: IconSettings, pending: true },
|
||||
{ key: 'config', label: '配置', icon: IconConfig },
|
||||
{ key: 'rules', label: '规则', icon: IconRules },
|
||||
{ key: 'requests', label: '日志', icon: IconLogs },
|
||||
{ key: 'connections', label: '连接', icon: IconConnections },
|
||||
{ key: 'settings', label: '设置', icon: IconSettings },
|
||||
]
|
||||
|
||||
+47
-4
@@ -1,23 +1,66 @@
|
||||
/**
|
||||
* 界面偏好,存在 localStorage 里。
|
||||
*
|
||||
* 目前只有一项:工具栏和导航是只显示图标,还是图标加文字。
|
||||
* 密度:工具栏和导航是只显示图标,还是图标加文字。
|
||||
* 默认是「完整」——这个工具是隔一阵子才动一次的,纯图标对偶尔来一次的人不友好;
|
||||
* 想要紧凑的人自己去设置里切。
|
||||
* 想收拢的人用侧栏底部的按钮切。
|
||||
*
|
||||
* 主题:跟随系统、浅色、深色。默认跟随系统。
|
||||
*
|
||||
* localStorage 和 document 都加了存在性守卫:api.ts 引用这里(测速地址),
|
||||
* 而 api.ts 被 bun test 环境里跑的模块间接引用——那里没有浏览器全局,
|
||||
* 模块顶层直接碰它们会把整个测试文件炸掉。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const storage = typeof localStorage === 'undefined' ? null : localStorage
|
||||
|
||||
export type Density = 'full' | 'compact'
|
||||
|
||||
const KEY = 'patchbay.density'
|
||||
|
||||
const stored = localStorage.getItem(KEY)
|
||||
const stored = storage?.getItem(KEY)
|
||||
export const density = ref<Density>(stored === 'compact' ? 'compact' : 'full')
|
||||
|
||||
watch(density, (value) => {
|
||||
localStorage.setItem(KEY, value)
|
||||
storage?.setItem(KEY, value)
|
||||
})
|
||||
|
||||
export function toggleDensity(): void {
|
||||
density.value = density.value === 'full' ? 'compact' : 'full'
|
||||
}
|
||||
|
||||
/** 测延迟用的目标地址。204 空响应的端点最合适,默认值全球都有部署。 */
|
||||
export const DEFAULT_TEST_URL = 'https://www.gstatic.com/generate_204'
|
||||
|
||||
const TEST_URL_KEY = 'patchbay.test-url'
|
||||
|
||||
export const testUrl = ref(storage?.getItem(TEST_URL_KEY) || DEFAULT_TEST_URL)
|
||||
|
||||
watch(testUrl, (value) => {
|
||||
if (value.trim() === '' || value === DEFAULT_TEST_URL) storage?.removeItem(TEST_URL_KEY)
|
||||
else storage?.setItem(TEST_URL_KEY, value.trim())
|
||||
})
|
||||
|
||||
export type Theme = 'system' | 'light' | 'dark'
|
||||
|
||||
const THEME_KEY = 'patchbay.theme'
|
||||
|
||||
const storedTheme = storage?.getItem(THEME_KEY)
|
||||
export const theme = ref<Theme>(
|
||||
storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'system',
|
||||
)
|
||||
|
||||
// 显式选了浅色或深色就在 <html> 上标出来:tokens.css 里 :root[data-theme=…] 的权重
|
||||
// 高于 :root 和媒体查询里的 :root,压过系统偏好。跟随系统则不加标记,交回给媒体查询,
|
||||
// 这样系统主题切换时界面仍然跟着变。
|
||||
watch(
|
||||
theme,
|
||||
(value) => {
|
||||
storage?.setItem(THEME_KEY, value)
|
||||
if (typeof document === 'undefined') return
|
||||
if (value === 'system') document.documentElement.removeAttribute('data-theme')
|
||||
else document.documentElement.setAttribute('data-theme', value)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "Patchbay",
|
||||
"short_name": "Patchbay",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#fbfbfc",
|
||||
"theme_color": "#fbfbfc",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
+168
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 内核的运行时状态:有哪些出站、每个是什么协议、策略组当前选中谁、最近一次延迟。
|
||||
*
|
||||
* 这些数据配置文件里没有,只有运行中的内核知道;而且界面上有三层组件要用它。
|
||||
* 原本它住在根组件里靠 props 一层层往下传,中间有两层完全不碰它、纯粹为了转发而
|
||||
* 声明了参数。做成模块级单例之后那两层就不用再知道它的存在了。
|
||||
*
|
||||
* 用的是 web/prefs.ts 已有的模式(模块级 ref),不是新抽象。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { api } from './api'
|
||||
import type { ProxyInfo } from './api'
|
||||
import { isGenerated } from '@shared/inbound-groups.ts'
|
||||
|
||||
/**
|
||||
* 出站名字 → 运行时信息。内核没连接时是空的。
|
||||
*
|
||||
* 合并了内核全局名字表和用户订阅里的线路。Patchbay 自己生成的订阅副本**不并进来**:
|
||||
* 副本里的线路和原版同名(决策记录 0007 里那套复制订阅声明的做法),并进来只会互相
|
||||
* 覆盖。要看副本里那一份,用下面的 `providers`。
|
||||
*/
|
||||
export const runtime = ref<Record<string, ProxyInfo>>({})
|
||||
|
||||
/** 订阅名 → 它当前提供的线路。含 Patchbay 生成的副本,按订阅隔开所以不会撞。 */
|
||||
export const providers = ref<Record<string, ProxyInfo[]>>({})
|
||||
|
||||
/** 用户自己的订阅带来的线路,按订阅返回的顺序。不含生成的副本。 */
|
||||
export const providerLines = ref<string[]>([])
|
||||
|
||||
/** 订阅线路 → 它所属的订阅名。测它的延迟必须从所属订阅进去。 */
|
||||
export const lineProvider = ref<Record<string, string>>({})
|
||||
|
||||
export const kernel = ref<{ online: boolean; version?: string }>({ online: false })
|
||||
|
||||
/** 只探测内核在不在线,不拉运行时数据。给外壳的定时心跳用,各页等内核回来的 watch 靠它可达。 */
|
||||
export async function refreshKernel(): Promise<void> {
|
||||
kernel.value = await api.kernel().catch(() => ({ online: false }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新读一遍运行时状态。
|
||||
*
|
||||
* 要读两处:`/proxies` 只包含内核全局名字表里的东西(内置出站、自有线路、策略组),
|
||||
* 订阅线路不在那张表上,它们的协议和延迟只能从 `/providers/proxies` 拿。
|
||||
*
|
||||
* 任何一处取不到都不算错误——界面照常显示,只是少了延迟数字。内核没起来的时候正是
|
||||
* 最需要这个界面的时候。
|
||||
*/
|
||||
export async function reloadRuntime(): Promise<void> {
|
||||
await refreshKernel()
|
||||
if (!kernel.value.online) {
|
||||
runtime.value = {}
|
||||
providers.value = {}
|
||||
providerLines.value = []
|
||||
lineProvider.value = {}
|
||||
return
|
||||
}
|
||||
|
||||
const merged: Record<string, ProxyInfo> = {}
|
||||
try {
|
||||
Object.assign(merged, (await api.proxies()).proxies)
|
||||
} catch {
|
||||
// 拿不到就当没有
|
||||
}
|
||||
|
||||
const byProvider: Record<string, ProxyInfo[]> = {}
|
||||
const lines: string[] = []
|
||||
const owner: Record<string, string> = {}
|
||||
try {
|
||||
const result = await api.providers()
|
||||
for (const [name, provider] of Object.entries(result.providers)) {
|
||||
// 内核会给每个用了 proxies 的策略组造一个内部 provider,那些不是订阅
|
||||
if (provider.vehicleType === 'Compatible') continue
|
||||
byProvider[name] = provider.proxies
|
||||
if (isGenerated(name)) continue
|
||||
for (const line of provider.proxies) {
|
||||
merged[line.name] = line
|
||||
lines.push(line.name)
|
||||
owner[line.name] = name
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
|
||||
runtime.value = merged
|
||||
providers.value = byProvider
|
||||
providerLines.value = lines
|
||||
lineProvider.value = owner
|
||||
}
|
||||
|
||||
/**
|
||||
* 界面渲染一个策略组需要知道的最少信息。
|
||||
*
|
||||
* `uses` 来自配置文件,`now` 来自内核,两边缺一不可:光有名字查不到它从哪取成员,
|
||||
* 而开了链式代理之后同名线路有两份,不知道来源就分不清该显示哪一份。
|
||||
*/
|
||||
export interface GroupView {
|
||||
name: string
|
||||
uses: string[]
|
||||
}
|
||||
|
||||
/** 一条线路在界面上要显示的样子,外加测它的延迟该从哪个订阅进去。 */
|
||||
export interface LineEntry {
|
||||
name: string
|
||||
/** 它来自哪个订阅。测延迟必须带上 */
|
||||
provider: string
|
||||
info: ProxyInfo | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 这条线路是不是来自 Patchbay 生成的订阅副本。
|
||||
*
|
||||
* 副本整份挂着 `dialer-proxy`,所以来自副本就一定经过入口。点名加进组里的成员
|
||||
* (`DIRECT`、AUTO 和 FALLBACK 那两个组)不属于任何订阅,不在副本里。
|
||||
*/
|
||||
export function isChainedLine(name: string): boolean {
|
||||
return Object.entries(providers.value).some(
|
||||
([provider, lines]) => isGenerated(provider) && lines.some((line) => line.name === name),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这几个订阅里一共有哪些线路。
|
||||
*
|
||||
* 只给配置表单里那个「匹配到几条」的预览用:那时用户选的来源还没保存,内核也还不知道
|
||||
* 有这么个组,只能自己按来源算一遍。已经存在的组要用下面的 `membersOf`。
|
||||
*/
|
||||
export function linesFrom(uses: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const provider of uses) {
|
||||
for (const line of providers.value[provider] ?? []) seen.add(line.name)
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个策略组的成员,按内核给的顺序。
|
||||
*
|
||||
* 成员名单直接用内核算好的(`/proxies` 里那个 `all`),不自己从来源订阅里推——组上的
|
||||
* `filter` 已经在内核那边生效过了,重推一遍就是把同一条规则实现两次,两边一旦不一致
|
||||
* 界面显示的就是假的。
|
||||
*
|
||||
* 每个成员还要标出它来自哪个订阅,因为测延迟得从所属订阅进去。顺着组自己的 `use:` 找而
|
||||
* 不是全局按名字查:开了链式代理之后同一个线路名在原订阅和副本里各有一份,只有顺着来源
|
||||
* 才知道该用哪一份。副本那份挂着 `dialer-proxy`,测出来是走完两跳的延迟,这正是这一路
|
||||
* 真实的延迟。点名加进来的成员(DIRECT、别的策略组)不属于任何订阅,`provider` 是空串。
|
||||
*/
|
||||
export function membersOf(view: GroupView): LineEntry[] {
|
||||
return (runtime.value[view.name]?.all ?? []).map((name) => {
|
||||
for (const provider of view.uses) {
|
||||
const line = (providers.value[provider] ?? []).find((item) => item.name === name)
|
||||
if (line) return { name, provider, info: line }
|
||||
}
|
||||
return { name, provider: '', info: runtime.value[name] }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换策略组选中谁时先在本地改掉,不等内核回话。
|
||||
*
|
||||
* 这一步不写配置文件、失败率极低,而等一个往返再更新会让点击感觉发木。
|
||||
* 真失败了随后的刷新会把状态纠正回来。
|
||||
*/
|
||||
export function optimisticSelect(group: string, member: string): void {
|
||||
const node = runtime.value[group]
|
||||
if (node) node.now = member
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { readLines } from './stream.ts'
|
||||
|
||||
function streamOf(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function collect(chunks: string[]): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
for await (const line of readLines(streamOf(chunks))) lines.push(line)
|
||||
return lines
|
||||
}
|
||||
|
||||
describe('按行读流', () => {
|
||||
it('一块里有好几行就拆开', async () => {
|
||||
expect(await collect(['a\nb\nc\n'])).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('半行留在缓冲里,等下一块补齐', async () => {
|
||||
// 分块边界和行边界无关,这是这个模块存在的全部理由
|
||||
expect(await collect(['{"up":1,"do', 'wn":2}\n{"up":3}\n'])).toEqual([
|
||||
'{"up":1,"down":2}',
|
||||
'{"up":3}',
|
||||
])
|
||||
})
|
||||
|
||||
it('流结束时缓冲里剩的半行也交出去', async () => {
|
||||
expect(await collect(['a\nb'])).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('空行丢弃,\\r\\n 换行的 \\r 去掉', async () => {
|
||||
expect(await collect(['a\r\n\n\nb\n'])).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('多字节字符跨块也不会读坏', async () => {
|
||||
const bytes = new TextEncoder().encode('香港01\n')
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// 故意从「港」字中间切开
|
||||
controller.enqueue(bytes.slice(0, 4))
|
||||
controller.enqueue(bytes.slice(4))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
const lines: string[] = []
|
||||
for await (const line of readLines(stream)) lines.push(line)
|
||||
expect(lines).toEqual(['香港01'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 把持续吐数据的 HTTP 响应体读成一行一行。
|
||||
*
|
||||
* 内核的 /traffic、/memory、/logs 在没有 WebSocket 升级时退化成分块流,一行一个
|
||||
* JSON。分块的边界和行的边界无关:一次 read 可能拿到半行,也可能拿到好几行。
|
||||
* 这里把它们缝成完整的行再交出去,空行丢弃。
|
||||
*
|
||||
* 停止读取的方式是中止 fetch(AbortController)或 break 出循环,两种都会走到
|
||||
* finally 里把流取消掉,不留悬着的连接。
|
||||
*/
|
||||
export async function* readLines(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const parts = buffer.split('\n')
|
||||
// 最后一段可能是半行,留在缓冲里等下一块补齐
|
||||
buffer = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
const line = part.endsWith('\r') ? part.slice(0, -1) : part
|
||||
if (line) yield line
|
||||
}
|
||||
}
|
||||
const rest = buffer.trim()
|
||||
if (rest) yield rest
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@
|
||||
*
|
||||
* 颜色只留一个强调色,用在「当前选中」和「主要操作」上。状态色(成功/警告/危险)
|
||||
* 只在真的表示状态时用,不拿来装饰。
|
||||
*
|
||||
* 唯一的例外是链式代理那一组色(--c-chain-*)。同一屏上有两种「当前选中」——流量最后
|
||||
* 从哪出去,和流量先经过哪——它们必须一眼分得开,这是含义不是装饰。挑靛蓝是因为它在
|
||||
* 这套色板里不表示任何状态,红绿琥珀都被状态占了。别把它当成通用的第二强调色。
|
||||
*/
|
||||
|
||||
:root {
|
||||
@@ -27,6 +31,11 @@
|
||||
--c-accent-soft: #e6f2f1;
|
||||
--c-accent-text: #1f5c5a;
|
||||
|
||||
/* 链式代理的「当前选中」。靛蓝,和强调色的青绿色相拉得够开 */
|
||||
--c-chain: #5145cd;
|
||||
--c-chain-soft: #eeedfb;
|
||||
--c-chain-text: #4038a8;
|
||||
|
||||
--c-danger: #b3261e;
|
||||
--c-danger-soft: #fdedec;
|
||||
--c-warn: #8a6100;
|
||||
@@ -62,6 +71,71 @@
|
||||
--line: 1.7;
|
||||
}
|
||||
|
||||
/*
|
||||
* 显式选择的主题(web/prefs.ts 在 <html> 上写 data-theme)。两个方向都要写:
|
||||
* 深色系统上选浅色也要生效。选择器权重高于 :root 和上面媒体查询里的 :root,
|
||||
* 显式选择压过系统偏好;不标 data-theme 时这两块不命中,媒体查询说了算。
|
||||
* 只覆盖颜色变量,间距、字号与主题无关。
|
||||
*/
|
||||
:root[data-theme='light'] {
|
||||
/* 变量之外还要告诉浏览器本身:滚动条、select 弹层这些 UA 部件不看变量,只看 color-scheme */
|
||||
color-scheme: light;
|
||||
|
||||
--c-bg: #fbfbfc;
|
||||
--c-surface: #ffffff;
|
||||
--c-surface-sunken: #f4f5f7;
|
||||
--c-border: #e4e6ea;
|
||||
--c-border-strong: #d0d3d9;
|
||||
|
||||
--c-text: #1c1e21;
|
||||
--c-text-soft: #5c6169;
|
||||
--c-text-faint: #8b9098;
|
||||
|
||||
--c-accent: #2b7a78;
|
||||
--c-accent-soft: #e6f2f1;
|
||||
--c-accent-text: #1f5c5a;
|
||||
|
||||
--c-chain: #5145cd;
|
||||
--c-chain-soft: #eeedfb;
|
||||
--c-chain-text: #4038a8;
|
||||
|
||||
--c-danger: #b3261e;
|
||||
--c-danger-soft: #fdedec;
|
||||
--c-warn: #8a6100;
|
||||
--c-warn-soft: #fdf5e3;
|
||||
--c-ok: #1f6b3a;
|
||||
--c-ok-soft: #e9f5ed;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--c-bg: #16181c;
|
||||
--c-surface: #1d2025;
|
||||
--c-surface-sunken: #121417;
|
||||
--c-border: #2b2f36;
|
||||
--c-border-strong: #3a3f48;
|
||||
|
||||
--c-text: #e6e8eb;
|
||||
--c-text-soft: #9ba1a9;
|
||||
--c-text-faint: #6c727a;
|
||||
|
||||
--c-accent: #4db6ac;
|
||||
--c-accent-soft: #1b3330;
|
||||
--c-accent-text: #7fd4cb;
|
||||
|
||||
--c-chain: #8b83f0;
|
||||
--c-chain-soft: #241f4a;
|
||||
--c-chain-text: #a9a2f7;
|
||||
|
||||
--c-danger: #f2b8b5;
|
||||
--c-danger-soft: #3a1f1e;
|
||||
--c-warn: #e8c37a;
|
||||
--c-warn-soft: #33290f;
|
||||
--c-ok: #86d6a0;
|
||||
--c-ok-soft: #16301f;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--c-bg: #16181c;
|
||||
@@ -78,6 +152,10 @@
|
||||
--c-accent-soft: #1b3330;
|
||||
--c-accent-text: #7fd4cb;
|
||||
|
||||
--c-chain: #8b83f0;
|
||||
--c-chain-soft: #241f4a;
|
||||
--c-chain-text: #a9a2f7;
|
||||
|
||||
--c-danger: #f2b8b5;
|
||||
--c-danger-soft: #3a1f1e;
|
||||
--c-warn: #e8c37a;
|
||||
@@ -99,6 +177,17 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 页面高度跨过视口阈值时滚动条出现消失,内容会横向抖一下;把滚动条的位置常驻留出来 */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* 模态框开着时锁住背后的页面滚动。:modal 只匹配 showModal() 打开的 dialog;
|
||||
滚动条的位置由上面的 scrollbar-gutter 常驻留出,锁定解锁都不会横向跳动 */
|
||||
html:has(:modal) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--c-bg);
|
||||
@@ -137,6 +226,61 @@ kbd,
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 文本输入的焦点态统一为描边变色,不叠外圈(订阅选择器的形态)。按钮保留外圈,那是键盘可达性 */
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
/* 交互控件的颜色过渡。只过渡颜色类属性,不碰布局;时长以「感知得到但不等待」为准 */
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* 对话框入场:快速淡入加一点上移 */
|
||||
dialog[open] {
|
||||
animation: dialog-in 0.15s ease;
|
||||
}
|
||||
|
||||
dialog[open]::backdrop {
|
||||
animation: backdrop-in 0.15s ease;
|
||||
}
|
||||
|
||||
/* 对话框退场:单纯的快速淡出。display/overlay 的 allow-discrete 让元素在
|
||||
过渡结束前留在 top layer;不支持的浏览器退化为立即消失,无害 */
|
||||
dialog,
|
||||
dialog::backdrop {
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
display 0.12s allow-discrete,
|
||||
overlay 0.12s allow-discrete;
|
||||
}
|
||||
|
||||
dialog:not([open]),
|
||||
dialog:not([open])::backdrop {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes dialog-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--c-accent-soft);
|
||||
color: var(--c-accent-text);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 常驻的遥测采集:流量曲线、内核内存、每路入站的吞吐。
|
||||
*
|
||||
* 住在模块级单例里而不是仪表盘组件里,是因为历史要跨页面留存——采集跟着组件
|
||||
* 走的话,切走一次曲线就清零一次。应用启动后由外壳调一次 startTelemetry(),
|
||||
* 之后跟着 kernel.online 自己起落:上线接上,掉线断开但**不清历史**,断档在
|
||||
* 时间轴上自然呈现为一段空白。
|
||||
*
|
||||
* 数据来源两路:
|
||||
* - /traffic、/memory 流,每秒一行(内核源码 hub/route/server.go:386-414)
|
||||
* - /connections 快照 3 秒一轮询,按连接逐条求差聚合出每路的吞吐
|
||||
* (快照本身没有流式 HTTP 版本,见 hub/route/connections.go:24-28)
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
ROUTE_POLL_MS,
|
||||
ROUTE_WINDOW_MS,
|
||||
TRAFFIC_WINDOW_MS,
|
||||
pushWindow,
|
||||
routeDeltas,
|
||||
} from '@shared/telemetry.ts'
|
||||
import type { ConnTotals, RouteSample, TrafficPoint } from '@shared/telemetry.ts'
|
||||
import type { ExitProbe } from '@shared/host-info.ts'
|
||||
import { api } from './api'
|
||||
import { kernel } from './runtime'
|
||||
import { readLines } from './stream'
|
||||
|
||||
/** 流量曲线,最近 30 分钟。up/down 是内核报的每秒字节数。 */
|
||||
export const traffic = ref<TrafficPoint[]>([])
|
||||
|
||||
/** 内核报的累计上下行字节数。没收到过是 null。 */
|
||||
export const totals = ref<{ up: number; down: number } | null>(null)
|
||||
|
||||
/** 内核自己的内存占用。 */
|
||||
export const kernelMemory = ref<number | null>(null)
|
||||
|
||||
/** 每路入站的吞吐样本,键是内核连接快照里的 inboundName。 */
|
||||
export const routeSamples = ref<Record<string, RouteSample[]>>({})
|
||||
|
||||
/** 当前连接数。 */
|
||||
export const connCount = ref<number | null>(null)
|
||||
|
||||
/** 流当前接着没有。断流期间仪表盘的「当前值」要显示成没数据,而不是冻在最后一秒。 */
|
||||
export const live = ref(false)
|
||||
|
||||
/** 每个端口的公网 IP 检测结果,会话内缓存。键是端口号。 */
|
||||
export const exitProbes = ref<Record<number, { state: 'loading' | 'done'; result?: ExitProbe }>>({})
|
||||
|
||||
/** 采集的时钟,每次轮询走一格。活跃判定这类「相对现在」的计算依赖它保持新鲜。 */
|
||||
export const tick = ref(Date.now())
|
||||
|
||||
const num = (value: unknown): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
|
||||
let abort: AbortController | null = null
|
||||
let pollTimer = 0
|
||||
let retryTimer = 0
|
||||
let prevTotals: ConnTotals = new Map()
|
||||
let prevAt = 0
|
||||
|
||||
async function consume(
|
||||
path: string,
|
||||
ctrl: AbortController,
|
||||
onLine: (payload: Record<string, unknown>) => void,
|
||||
) {
|
||||
const body = await api.openStream(path, ctrl.signal)
|
||||
for await (const line of readLines(body)) {
|
||||
// 偶发的坏行跳过就行,抛出去会杀掉整个流、进 5 秒重试循环
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = JSON.parse(line) as Record<string, unknown>
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
onLine(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function startStreams() {
|
||||
const ctrl = new AbortController()
|
||||
abort = ctrl
|
||||
live.value = true
|
||||
const run = (path: string, onLine: (payload: Record<string, unknown>) => void) =>
|
||||
void consume(path, ctrl, onLine)
|
||||
.catch(() => {})
|
||||
// 这两个流本身永不结束,正常返回也意味着断了
|
||||
.finally(() => {
|
||||
if (ctrl.signal.aborted) return
|
||||
// 一条断了把另一条也收掉,然后走统一的重连节奏
|
||||
ctrl.abort()
|
||||
live.value = false
|
||||
scheduleRetry()
|
||||
})
|
||||
|
||||
run('/traffic', (p) => {
|
||||
traffic.value = pushWindow(
|
||||
traffic.value,
|
||||
{ t: Date.now(), up: num(p.up), down: num(p.down) },
|
||||
TRAFFIC_WINDOW_MS,
|
||||
)
|
||||
totals.value = { up: num(p.upTotal), down: num(p.downTotal) }
|
||||
})
|
||||
run('/memory', (p) => {
|
||||
kernelMemory.value = num(p.inuse)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 断流不等于内核没了:保存配置触发的重载会掐断流,内核几秒内就回来、online 从未
|
||||
* 翻转。5 秒后重试,失败会再次走到这里形成节奏;真掉线交给下面 online 的 watch。
|
||||
*/
|
||||
function scheduleRetry() {
|
||||
window.clearTimeout(retryTimer)
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (kernel.value.online && !live.value) startStreams()
|
||||
}, 5_000)
|
||||
}
|
||||
|
||||
async function pollConnections() {
|
||||
const now = Date.now()
|
||||
try {
|
||||
const snapshot = await api.connections()
|
||||
const conns = snapshot.connections ?? []
|
||||
connCount.value = conns.length
|
||||
const { deltas, next } = routeDeltas(prevTotals, conns)
|
||||
const ms = prevAt ? now - prevAt : ROUTE_POLL_MS
|
||||
prevTotals = next
|
||||
prevAt = now
|
||||
|
||||
const merged = { ...routeSamples.value }
|
||||
for (const [key, delta] of Object.entries(deltas)) {
|
||||
merged[key] = pushWindow(
|
||||
merged[key] ?? [],
|
||||
{ t: now, ms, up: delta.up, down: delta.down },
|
||||
ROUTE_WINDOW_MS,
|
||||
)
|
||||
}
|
||||
// 有历史但这一轮没有连接的路,补零样本让走势落回去而不是冻住;
|
||||
// 整个窗口都归零之后把这路忘掉,改名、删除的入站才不会在内存里越攒越多
|
||||
for (const key of Object.keys(merged)) {
|
||||
if (key in deltas) continue
|
||||
const samples = pushWindow(merged[key]!, { t: now, ms, up: 0, down: 0 }, ROUTE_WINDOW_MS)
|
||||
if (samples.every((s) => s.up === 0 && s.down === 0)) delete merged[key]
|
||||
else merged[key] = samples
|
||||
}
|
||||
routeSamples.value = merged
|
||||
} catch {
|
||||
// 单轮失败就跳过。持续失败说明内核掉了,online 的 watch 会把轮询停掉
|
||||
} finally {
|
||||
tick.value = now
|
||||
}
|
||||
}
|
||||
|
||||
function attach() {
|
||||
if (!abort) startStreams()
|
||||
if (!pollTimer) {
|
||||
void pollConnections()
|
||||
pollTimer = window.setInterval(() => void pollConnections(), ROUTE_POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function detach() {
|
||||
abort?.abort()
|
||||
abort = null
|
||||
live.value = false
|
||||
window.clearInterval(pollTimer)
|
||||
window.clearTimeout(retryTimer)
|
||||
pollTimer = 0
|
||||
prevTotals = new Map()
|
||||
prevAt = 0
|
||||
}
|
||||
|
||||
let started = false
|
||||
|
||||
/** 外壳登录成功后调一次。之后采集跟着内核在线状态自己起落。 */
|
||||
export function startTelemetry(): void {
|
||||
if (started) return
|
||||
started = true
|
||||
watch(
|
||||
() => kernel.value.online,
|
||||
(online) => {
|
||||
if (online) attach()
|
||||
else detach()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
|
||||
/** 检测某个端口的公网 IP。结果缓存到会话结束,force 重测。 */
|
||||
export async function probeExit(port: number, force = false): Promise<void> {
|
||||
const cached = exitProbes.value[port]
|
||||
if (!force && cached && (cached.state === 'loading' || cached.result?.ok)) return
|
||||
exitProbes.value = { ...exitProbes.value, [port]: { state: 'loading' } }
|
||||
const result = await api
|
||||
.probeExit(port)
|
||||
.catch((error: unknown) => ({ ok: false, message: String(error) }) as ExitProbe)
|
||||
exitProbes.value = { ...exitProbes.value, [port]: { state: 'done', result } }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 图表取色。
|
||||
*
|
||||
* Canvas 里画的东西不认 CSS 变量,颜色要在 JS 里解析出来传给 ECharts。
|
||||
* themeTick 在主题变化(设置页显式切换写 data-theme,或系统偏好翻转)时走一格,
|
||||
* 图表的 option 把它算进依赖,主题一变整个重新取色。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const themeTick = ref(0)
|
||||
|
||||
const bump = () => {
|
||||
themeTick.value += 1
|
||||
}
|
||||
|
||||
new MutationObserver(bump).observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
})
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', bump)
|
||||
|
||||
/** 当前生效的某个 CSS 变量的值,如 cssColor('--c-accent')。 */
|
||||
export function cssColor(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 右上角的轻提示(成就弹窗式):从右滑入一行字,几秒后上移淡出。
|
||||
*
|
||||
* 模块级单例,谁都能发;渲染在外壳的 ToastHost 里。适合「动作已完成/失败」
|
||||
* 这类看一眼就行的反馈;需要用户处理的内容(表单校验、带重试的错误)
|
||||
* 仍然留在页面里,不进这里。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { ApiError } from './api'
|
||||
|
||||
export type ToastKind = 'ok' | 'error' | 'info'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
kind: ToastKind
|
||||
message: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
export const toasts = ref<Toast[]>([])
|
||||
|
||||
let nextId = 1
|
||||
|
||||
export function toast(message: string, opts?: { kind?: ToastKind; hint?: string }): void {
|
||||
const item: Toast = {
|
||||
id: nextId++,
|
||||
kind: opts?.kind ?? 'ok',
|
||||
message,
|
||||
hint: opts?.hint ?? '',
|
||||
}
|
||||
toasts.value = [...toasts.value, item]
|
||||
// 错误多留一会儿,看清 hint 里的下一步
|
||||
const ttl = item.kind === 'error' ? 8000 : 4000
|
||||
setTimeout(() => dismiss(item.id), ttl)
|
||||
}
|
||||
|
||||
export function dismiss(id: number): void {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}
|
||||
|
||||
/** 接口报错的统一出口:message 说发生了什么,hint 说下一步。 */
|
||||
export function toastError(error: unknown): void {
|
||||
if (error instanceof ApiError) toast(error.message, { kind: 'error', hint: error.hint })
|
||||
else toast(String(error), { kind: 'error' })
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 配置页:订阅的导入和管理。左列表右详情,同路由页的主从布局。
|
||||
*
|
||||
* 编排守的分工和路由页一样:增删改订阅都走 commit(),带校验和重载,失败按
|
||||
* message/hint 说;「立刻更新」是让内核重新拉取的运行时动作,不写配置文件,
|
||||
* 不走 commit(),也没有保存那种等待感。
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { collectInbounds } from '@shared/inbounds.ts'
|
||||
import { pruneGenerated, syncGenerated } from '@shared/inbound-groups.ts'
|
||||
import {
|
||||
addFileSubscription,
|
||||
addSubscription,
|
||||
listSubscriptions,
|
||||
moveSubscription,
|
||||
providerNames,
|
||||
removeSubscription,
|
||||
renameSubscription,
|
||||
setSubscriptionHealthCheck,
|
||||
subscriptionPathConflict,
|
||||
subscriptionReferences,
|
||||
updateSubscription,
|
||||
} from '@shared/providers.ts'
|
||||
import type { Subscription } from '@shared/providers.ts'
|
||||
import { providerCachePath } from '@shared/inbound-groups.ts'
|
||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||
import { ApiError, api } from '../api'
|
||||
import { toast, toastError } from '../toast'
|
||||
import type { ProviderInfo } from '../api'
|
||||
import { kernel, providerLines, reloadRuntime } from '../runtime'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import ProviderList from '../components/ProviderList.vue'
|
||||
import SubscriptionDetail from './SubscriptionDetail.vue'
|
||||
import SubscriptionForm from './SubscriptionForm.vue'
|
||||
import IconAdd from '~icons/ri/add-line'
|
||||
import IconRefresh from '~icons/ri/refresh-line'
|
||||
|
||||
const props = defineProps<{ data: ConfigObject; revision: string }>()
|
||||
|
||||
/** 配置文件变了,需要重读整份配置。运行时的变化不走这里。 */
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
|
||||
const subscriptions = computed(() => listSubscriptions(props.data))
|
||||
|
||||
const selectedName = ref<string | null>(null)
|
||||
const saving = ref(false)
|
||||
const formOpen = ref(false)
|
||||
const formEditing = ref<Subscription | null>(null)
|
||||
/** 待确认删除的那个订阅。非 null 时确认对话框打开着 */
|
||||
const removing = ref<Subscription | null>(null)
|
||||
/** 正在等内核重新拉取的订阅名 */
|
||||
const refreshing = ref<string | null>(null)
|
||||
/** 正在等内核测完线路的订阅名 */
|
||||
const checking = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 每个订阅的运行时数据:当前线路、上次更新时间。
|
||||
*
|
||||
* 配置文件里只有地址和间隔,这些只有运行中的内核知道。runtime.ts 那份合并视图
|
||||
* 没有 updatedAt,而它归路由页共用、不归这个页面改,所以这里自己留一份。
|
||||
* 内核没连接时就是空的,页面照常显示配置里有的东西(决策记录 0004)。
|
||||
*/
|
||||
const providerInfo = ref<Record<string, ProviderInfo>>({})
|
||||
|
||||
async function reloadProviders() {
|
||||
if (!kernel.value.online) {
|
||||
providerInfo.value = {}
|
||||
return
|
||||
}
|
||||
providerInfo.value = await api
|
||||
.providers()
|
||||
.then((result) => result.providers)
|
||||
.catch(() => ({}))
|
||||
}
|
||||
|
||||
onMounted(() => void reloadProviders())
|
||||
// 保存成功后父组件重读配置,data 换了就顺带把运行时数据也换新的
|
||||
watch(() => props.data, () => void reloadProviders())
|
||||
|
||||
// 默认选中第一行。空面板没有信息量,不如直接展示一个订阅。
|
||||
watch(
|
||||
subscriptions,
|
||||
(list) => {
|
||||
if (!list.some((s) => s.name === selectedName.value)) selectedName.value = list[0]?.name ?? null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const selected = computed(
|
||||
() => subscriptions.value.find((s) => s.name === selectedName.value) ?? null,
|
||||
)
|
||||
|
||||
/** 新增表单挡重名要用全部键:和用户看不见的订阅副本撞名也会被静默丢掉 */
|
||||
const takenNames = computed(() => providerNames(props.data))
|
||||
|
||||
/** Go 零值时间(0001-01-01)表示从没拉取过,按没有处理 */
|
||||
function parseUpdated(at: string | undefined): Date | null {
|
||||
if (!at) return null
|
||||
const d = new Date(at)
|
||||
return Number.isNaN(d.getTime()) || d.getFullYear() < 2000 ? null : d
|
||||
}
|
||||
|
||||
const rows = computed(() =>
|
||||
subscriptions.value.map((s) => {
|
||||
const info = providerInfo.value[s.name]
|
||||
return {
|
||||
name: s.name,
|
||||
lines: info ? info.proxies.length : null,
|
||||
updated: parseUpdated(info?.updatedAt),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const clone = () => JSON.parse(JSON.stringify(props.data)) as ConfigObject
|
||||
|
||||
/** 所有改配置文件的动作都从这里走。反馈走右上角轻提示,返回值告知成败。 */
|
||||
async function commit(raw: ConfigObject, success: string): Promise<boolean> {
|
||||
// 先把生成的东西按当前规则重建一遍,再清掉没人再引用的。订阅副本的来源被改地址时
|
||||
// 靠这一步保持和原订阅一致,删订阅前已由 subscriptionReferences 挡住有引用的情形。
|
||||
const next = pruneGenerated(syncGenerated(raw, collectInbounds(raw)))
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await api.applyConfig(next, props.revision)
|
||||
// 没重载有两种情况(内容没有变化、内核不在线只写了文件),都要如实说,不能只报成功
|
||||
toast(result.reloaded ? success : `${success}(${result.reloadMessage})`)
|
||||
emit('saved')
|
||||
return true
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
formEditing.value = null
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (!selected.value || saving.value) return
|
||||
formEditing.value = selected.value
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
async function submitForm(draft: {
|
||||
name: string
|
||||
url: string
|
||||
interval: number | null
|
||||
file: { name: string; content: string } | null
|
||||
}) {
|
||||
const editing = formEditing.value
|
||||
|
||||
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦
|
||||
// (只查新增:改名不动缓存路径,不会制造新的撞车)
|
||||
if (!editing) {
|
||||
const path = providerCachePath(draft.name)
|
||||
const clash = subscriptionPathConflict(props.data, draft.name, path)
|
||||
if (clash) {
|
||||
toast(`「${draft.name}」和「${clash}」的缓存文件会是同一个`, {
|
||||
kind: 'error',
|
||||
hint: '名字只在符号或空格上有差异时会净化成同一个文件名,换一个名字。',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 从文件导入:先把内容写到订阅的缓存路径上,成了再写声明。
|
||||
// 顺序不能反——声明先落盘的话,内核重载会去读一个还不存在的文件
|
||||
if (draft.file) {
|
||||
try {
|
||||
await api.uploadProviderFile(providerCachePath(draft.name), draft.file.content)
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑时先改名再改字段。名字没动时 renameSubscription 原样返回;
|
||||
// file 类型只有名称可改,不能走 updateSubscription——它会写进 url
|
||||
const next = editing
|
||||
? editing.type === 'file'
|
||||
? renameSubscription(clone(), editing.name, draft.name)
|
||||
: updateSubscription(
|
||||
renameSubscription(clone(), editing.name, draft.name),
|
||||
draft.name,
|
||||
draft.url,
|
||||
draft.interval,
|
||||
)
|
||||
: draft.file
|
||||
? addFileSubscription(clone(), draft.name)
|
||||
: addSubscription(clone(), draft.name, draft.url, draft.interval)
|
||||
const ok = await commit(next, editing ? '已保存' : `已新增 ${draft.name}`)
|
||||
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
|
||||
if (!ok) return
|
||||
formOpen.value = false
|
||||
// 新增和改名都要把选中跟过去,否则选中名失效会被重置到第一行
|
||||
selectedName.value = draft.name
|
||||
}
|
||||
|
||||
function askRemove() {
|
||||
const target = selected.value
|
||||
if (!target || saving.value) return
|
||||
// 被引用的订阅不能删:那些组的 use: 一空,指向它们的入站就无路可走
|
||||
const refs = subscriptionReferences(props.data, target.name)
|
||||
if (refs.length > 0) {
|
||||
toast(`无法删除 ${target.name}:${refs.join('、')} 在引用它`, { kind: 'error' })
|
||||
return
|
||||
}
|
||||
removing.value = target
|
||||
}
|
||||
|
||||
async function removeConfirmed() {
|
||||
const target = removing.value
|
||||
removing.value = null
|
||||
if (!target || saving.value) return
|
||||
selectedName.value = null
|
||||
await commit(removeSubscription(clone(), target.name), `已删除 ${target.name}`)
|
||||
}
|
||||
|
||||
/** 开关选中订阅的健康检查。写配置文件,走 commit()。 */
|
||||
async function toggleHealthCheck() {
|
||||
const target = selected.value
|
||||
if (!target || saving.value) return
|
||||
const enable = target.healthCheck === null
|
||||
await commit(
|
||||
setSubscriptionHealthCheck(clone(), target.name, enable),
|
||||
enable ? `已开启 ${target.name} 的健康检查` : `已关闭 ${target.name} 的健康检查`,
|
||||
)
|
||||
}
|
||||
|
||||
/** 让内核立刻重新拉取。运行时动作,不写配置文件,所以不走 commit()。 */
|
||||
async function refresh() {
|
||||
const target = selected.value
|
||||
if (!target || refreshing.value !== null) return
|
||||
refreshing.value = target.name
|
||||
try {
|
||||
await api.refreshProvider(target.name)
|
||||
await Promise.all([reloadRuntime(), reloadProviders()])
|
||||
toast(`已更新 ${target.name}`)
|
||||
} catch (error) {
|
||||
toast(error instanceof ApiError ? error.message : String(error), {
|
||||
kind: 'error',
|
||||
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
|
||||
})
|
||||
} finally {
|
||||
refreshing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 让内核立刻测一遍选中订阅的线路。运行时动作,不写配置文件,所以不走 commit()。
|
||||
*
|
||||
* 内核是测完全部线路才回话的,线路多又都测不通时要等上几十秒,期间按钮转着。
|
||||
*/
|
||||
async function check() {
|
||||
const target = selected.value
|
||||
if (!target || checking.value !== null) return
|
||||
checking.value = target.name
|
||||
try {
|
||||
await api.checkProviderLines(target.name)
|
||||
await reloadProviders()
|
||||
toast(`已检查 ${target.name} 的线路`)
|
||||
} catch (error) {
|
||||
toast(error instanceof ApiError ? error.message : String(error), {
|
||||
kind: 'error',
|
||||
hint: '该操作仅修改内核运行状态,配置文件未被修改。',
|
||||
})
|
||||
} finally {
|
||||
checking.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 把一个订阅在列表里挪一位。顺序在配置文件里,所以要走 commit()。 */
|
||||
async function move(name: string, delta: -1 | 1) {
|
||||
if (saving.value) return
|
||||
const before = clone()
|
||||
const next = moveSubscription(before, name, delta)
|
||||
// 到头了 moveSubscription 原样返回,这时不该发起一次什么都没改的保存
|
||||
if (next === before) return
|
||||
await commit(next, '顺序已保存')
|
||||
}
|
||||
|
||||
const refreshingAll = ref(false)
|
||||
|
||||
/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */
|
||||
async function refreshAll() {
|
||||
if (refreshingAll.value || !kernel.value.online || subscriptions.value.length === 0) return
|
||||
refreshingAll.value = true
|
||||
const names = subscriptions.value.map((s) => s.name)
|
||||
const results = await Promise.allSettled(names.map((n) => api.refreshProvider(n)))
|
||||
await Promise.all([reloadRuntime(), reloadProviders()])
|
||||
const failed = names.filter((_, i) => results[i]?.status === 'rejected')
|
||||
if (failed.length > 0) {
|
||||
toast(`${failed.join('、')} 更新失败`, {
|
||||
kind: 'error',
|
||||
hint: '其余订阅已更新。该操作仅修改内核运行状态,配置文件未被修改。',
|
||||
})
|
||||
} else {
|
||||
toast(`已更新全部 ${names.length} 个订阅`)
|
||||
}
|
||||
refreshingAll.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<div class="sums">
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ subscriptions.length }}</span>
|
||||
<span class="sum-k">订阅</span>
|
||||
</div>
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ providerLines.length }}</span>
|
||||
<span class="sum-k">订阅线路</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="head-acts">
|
||||
<button
|
||||
class="add"
|
||||
type="button"
|
||||
:disabled="refreshingAll || !kernel.online || subscriptions.length === 0"
|
||||
@click="refreshAll"
|
||||
>
|
||||
<IconRefresh />
|
||||
<span>{{ refreshingAll ? '更新中' : '更新所有订阅' }}</span>
|
||||
</button>
|
||||
<button class="add" type="button" :disabled="saving" @click="openAdd">
|
||||
<IconAdd />
|
||||
<span>新增</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="split">
|
||||
<ProviderList
|
||||
v-model:selected="selectedName"
|
||||
:rows="rows"
|
||||
:saving="saving"
|
||||
@move="move"
|
||||
/>
|
||||
|
||||
<SubscriptionDetail
|
||||
v-if="selected"
|
||||
:subscription="selected"
|
||||
:info="providerInfo[selected.name]"
|
||||
:online="kernel.online"
|
||||
:refreshing="refreshing === selected.name"
|
||||
:checking="checking === selected.name"
|
||||
:saving="saving"
|
||||
@edit="openEdit"
|
||||
@remove="askRemove"
|
||||
@refresh="refresh"
|
||||
@check="check"
|
||||
@toggle-health-check="toggleHealthCheck"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="subscriptions.length === 0" class="empty">没有订阅。</p>
|
||||
|
||||
<SubscriptionForm
|
||||
:open="formOpen"
|
||||
:editing="formEditing"
|
||||
:taken-names="takenNames"
|
||||
:saving="saving"
|
||||
@close="formOpen = false"
|
||||
@submit="submitForm"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="removing !== null"
|
||||
:message="removing ? `删除订阅「${removing.name}」?` : ''"
|
||||
action="删除"
|
||||
@close="removing = null"
|
||||
@confirm="removeConfirmed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-4);
|
||||
margin-bottom: var(--s-5);
|
||||
}
|
||||
|
||||
.sums {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.sum {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.sum-n {
|
||||
font-size: var(--t-2xl);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sum-k {
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.add:hover:not(:disabled) {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
.add:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.head-acts {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
/* 左列表右详情。窄时改成上下,详情不再吸顶 */
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr);
|
||||
gap: var(--s-5);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* 按内容区宽度切换而不是按视口:侧栏能收拢,同一个视口宽度下内容区可宽可窄 */
|
||||
.page {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@container (max-width: 900px) {
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
margin-top: var(--s-4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,483 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 连接页:内核当前有哪些连接,从哪一路入站进来,能断开它们。
|
||||
*
|
||||
* /connections 没有流式版本,只能每秒全量轮询一次。表格数据来自内核快照,
|
||||
* 显示层的问题(主机名怎么摆、出站链按什么方向、持续时间多长)都在 shared/connections.ts
|
||||
* 里回答,这里只管取数、状态和断开动作。
|
||||
*/
|
||||
import { computed, onActivated, onDeactivated, onUnmounted, ref } from 'vue'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
import {
|
||||
chainLabel,
|
||||
filterConnections,
|
||||
formatDuration,
|
||||
groupByInbound,
|
||||
hostLabel,
|
||||
ruleLabel,
|
||||
sortConnections,
|
||||
} from '@shared/connections.ts'
|
||||
import type { ConnectionSnapshot, SortKey } from '@shared/connections.ts'
|
||||
import { formatBytes } from '@shared/format.ts'
|
||||
import { ApiError, api } from '../api'
|
||||
import { toast, toastError } from '../toast'
|
||||
import { kernel } from '../runtime'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
|
||||
const snapshot = ref<ConnectionSnapshot | null>(null)
|
||||
/**
|
||||
* 轮询取数的失败,成功时自己清掉。这是持续状态所以留在页内;
|
||||
* 断开动作的失败是一次性事件,走右上角轻提示。
|
||||
*/
|
||||
const pollFailure = ref('')
|
||||
/** 表格里的持续时间按这一刻算。跟着轮询节拍走,不单独开秒表 */
|
||||
const now = ref(Date.now())
|
||||
const query = ref('')
|
||||
const sort = ref<SortKey>('download')
|
||||
/** 正在断开中的连接 id,防止连点 */
|
||||
const closing = ref<string[]>([])
|
||||
const closingAll = ref(false)
|
||||
const askAll = ref(false)
|
||||
/** 有请求在飞就不再发,内核卡住时每秒叠一个请求只会让局面更糟 */
|
||||
let fetching = false
|
||||
let timer: number | undefined
|
||||
|
||||
/**
|
||||
* 轮询的取数失败和断开动作的失败分开放。共用一条通道的话,断开失败的提示
|
||||
* 会被下一秒轮询成功时的清空抹掉,用户只看到那条连接还在、不知道为什么。
|
||||
*/
|
||||
async function refresh() {
|
||||
// 内核不在线时不打这个接口:得不到数据,界面直接说原因
|
||||
if (!kernel.value.online || fetching) return
|
||||
fetching = true
|
||||
try {
|
||||
snapshot.value = await api.connections()
|
||||
now.value = Date.now()
|
||||
pollFailure.value = ''
|
||||
} catch (error) {
|
||||
pollFailure.value = error instanceof ApiError ? error.message : String(error)
|
||||
} finally {
|
||||
fetching = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件包在 KeepAlive 里:首次挂载和每次切回都走 activated(挂载时它也会触发,
|
||||
// 不能再用 onMounted 起轮询,否则会叠两个定时器),切走时停掉轮询不白打后端
|
||||
onActivated(() => {
|
||||
void refresh()
|
||||
timer = window.setInterval(() => void refresh(), 1000)
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
window.clearInterval(timer)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearInterval(timer)
|
||||
})
|
||||
|
||||
const all = computed(() => snapshot.value?.connections ?? [])
|
||||
const rows = computed(() =>
|
||||
sortConnections(filterConnections(all.value, query.value), sort.value, now.value),
|
||||
)
|
||||
const uses = computed(() => groupByInbound(all.value))
|
||||
|
||||
/* 行高写死,estimateSize 必须和它一致,否则滚动位置算不准 */
|
||||
const ROW_HEIGHT = 48
|
||||
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
const virtualizer = useVirtualizer(
|
||||
computed(() => ({
|
||||
count: rows.value.length,
|
||||
getScrollElement: () => scrollEl.value,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 8,
|
||||
})),
|
||||
)
|
||||
const virtualRows = computed(() => virtualizer.value.getVirtualItems())
|
||||
const totalSize = computed(() => virtualizer.value.getTotalSize())
|
||||
|
||||
async function closeOne(id: string) {
|
||||
if (closing.value.includes(id) || closingAll.value) return
|
||||
closing.value = [...closing.value, id]
|
||||
try {
|
||||
await api.closeConnection(id)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
closing.value = closing.value.filter((item) => item !== id)
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAllConfirmed() {
|
||||
askAll.value = false
|
||||
if (closingAll.value) return
|
||||
closingAll.value = true
|
||||
try {
|
||||
await api.closeAllConnections()
|
||||
await refresh()
|
||||
toast('已断开所有连接')
|
||||
} catch (error) {
|
||||
toastError(error)
|
||||
} finally {
|
||||
closingAll.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<div class="sums">
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ all.length }}</span>
|
||||
<span class="sum-k">连接</span>
|
||||
</div>
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ formatBytes(snapshot?.uploadTotal ?? 0) }}</span>
|
||||
<span class="sum-k">上传总量</span>
|
||||
</div>
|
||||
<div class="sum">
|
||||
<span class="sum-n">{{ formatBytes(snapshot?.downloadTotal ?? 0) }}</span>
|
||||
<span class="sum-k">下载总量</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tools">
|
||||
<input v-model="query" type="search" class="search" placeholder="按主机筛选" />
|
||||
<button
|
||||
type="button"
|
||||
class="close-all"
|
||||
:disabled="all.length === 0 || closingAll"
|
||||
@click="askAll = true"
|
||||
>
|
||||
全部断开
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="pollFailure" class="failure">
|
||||
<strong class="failure-text">{{ pollFailure }}</strong>
|
||||
<button type="button" class="failure-x" title="关闭提示" @click="pollFailure = ''">
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!kernel.online" class="note">内核未连接,看不到当前有哪些连接。</p>
|
||||
<p v-else-if="!snapshot" class="note">读取中</p>
|
||||
<p v-else-if="all.length === 0" class="note">当前没有连接。</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- 这个项目特有的视角:每一路入站(每个容器)现在有多少条连接 -->
|
||||
<div class="uses">
|
||||
<span v-for="use in uses" :key="use.label" class="use">
|
||||
{{ use.label }}
|
||||
<strong>{{ use.count }}</strong> 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="rows.length === 0" class="note">没有主机匹配「{{ query.trim() }}」。</p>
|
||||
|
||||
<div v-else class="table">
|
||||
<!-- 表头在滚动容器里而不是外面:.scroller 声明 overflow-y 后按规范会连带
|
||||
拿到自己的横向滚动上下文,表头留在外面的话窄屏横向滚动时两者会脱节。
|
||||
竖直方向靠 sticky 钉住。 -->
|
||||
<div ref="scrollEl" class="scroller">
|
||||
<div class="rowgrid thead">
|
||||
<span>主机</span>
|
||||
<span>规则</span>
|
||||
<span>出站链</span>
|
||||
<span class="num">上传</span>
|
||||
<button
|
||||
type="button"
|
||||
:class="['th num', sort === 'download' && 'is-on']"
|
||||
@click="sort = 'download'"
|
||||
>
|
||||
下载{{ sort === 'download' ? ' ↓' : '' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="['th num', sort === 'duration' && 'is-on']"
|
||||
@click="sort = 'duration'"
|
||||
>
|
||||
持续时间{{ sort === 'duration' ? ' ↓' : '' }}
|
||||
</button>
|
||||
<span>入站</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="spacer" :style="{ height: `${totalSize}px` }">
|
||||
<div
|
||||
v-for="vRow in virtualRows"
|
||||
:key="rows[vRow.index]?.id ?? vRow.index"
|
||||
class="rowgrid row"
|
||||
:style="{ transform: `translateY(${vRow.start}px)` }"
|
||||
>
|
||||
<template v-if="rows[vRow.index]">
|
||||
<span class="cell mono" :title="hostLabel(rows[vRow.index]!.metadata)">
|
||||
{{ hostLabel(rows[vRow.index]!.metadata) }}
|
||||
</span>
|
||||
<span class="cell mono">{{ ruleLabel(rows[vRow.index]!.rule, rows[vRow.index]!.rulePayload) }}</span>
|
||||
<span class="cell mono" :title="chainLabel(rows[vRow.index]!.chains)">
|
||||
{{ chainLabel(rows[vRow.index]!.chains) }}
|
||||
</span>
|
||||
<span class="cell num mono">{{ formatBytes(rows[vRow.index]!.upload) }}</span>
|
||||
<span class="cell num mono">{{ formatBytes(rows[vRow.index]!.download) }}</span>
|
||||
<span class="cell num mono">{{ formatDuration(rows[vRow.index]!.start, now) }}</span>
|
||||
<span class="cell mono">{{ rows[vRow.index]!.metadata.inboundName }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="drop"
|
||||
title="断开这条连接"
|
||||
:disabled="closing.includes(rows[vRow.index]!.id) || closingAll"
|
||||
@click="closeOne(rows[vRow.index]!.id)"
|
||||
>
|
||||
<IconClose />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="askAll"
|
||||
:message="`断开全部 ${String(all.length)} 条连接?正在进行的传输会中断。`"
|
||||
action="全部断开"
|
||||
@close="askAll = false"
|
||||
@confirm="closeAllConfirmed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-4);
|
||||
margin-bottom: var(--s-5);
|
||||
}
|
||||
|
||||
.sums {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
|
||||
.sum {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.sum-n {
|
||||
font-size: var(--t-xl);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sum-k {
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 200px;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
}
|
||||
|
||||
.close-all {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.close-all:hover:not(:disabled) {
|
||||
border-color: var(--c-danger);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.close-all:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.failure {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-3);
|
||||
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, var(--c-border));
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-danger-soft);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
margin-bottom: var(--s-4);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.failure-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.failure-x {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
.failure-x:hover {
|
||||
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.uses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
margin-bottom: var(--s-4);
|
||||
}
|
||||
|
||||
.use {
|
||||
padding: var(--s-1) var(--s-3);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 999px;
|
||||
background: var(--c-surface);
|
||||
font-size: var(--t-sm);
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.use strong {
|
||||
color: var(--c-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.table {
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
/* 只负责圆角裁切;横竖两个方向的滚动都归 .scroller 一个容器管 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rowgrid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(160px, 1.5fr) minmax(120px, 1fr) minmax(160px, 1.5fr)
|
||||
5rem 5rem 6.5rem minmax(90px, 0.8fr) 2rem;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
min-width: 880px;
|
||||
padding: 0 var(--s-4);
|
||||
}
|
||||
|
||||
/* 表头随内容横向滚动(同一个容器),竖直方向 sticky 钉在顶上。
|
||||
行是绝对定位的,要 z-index 才能压住;背景不透明才不会透出滚过的行 */
|
||||
.thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
|
||||
/* 可排序的列表头。用按钮但视觉上仍是表头文字,排序中的那列才显色 */
|
||||
.th {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
}
|
||||
.th:hover {
|
||||
color: var(--c-text);
|
||||
}
|
||||
.th.is-on {
|
||||
color: var(--c-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
overflow: auto;
|
||||
/* 表格最长不顶满一屏:留出头部和汇总条的位置,连接少时随内容缩短 */
|
||||
max-height: calc(100vh - 20rem);
|
||||
/* 表头占 40px,虚拟滚动的可见窗口按纯行高算会偏这 40px——overscan 8 行
|
||||
(384px)足以盖住,不必给虚拟器配 scrollMargin */
|
||||
}
|
||||
|
||||
.spacer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.cell {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.drop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.drop:hover:not(:disabled) {
|
||||
background: var(--c-danger-soft);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
.drop:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 追加 Hosts 的编辑弹窗:配置顶层的 `hosts:` 映射(config.go:454,值可以是
|
||||
* 单个 IP/域名或数组,parseHosts config.go:1131)。
|
||||
*
|
||||
* 在弹窗里随便改,点保存才一次性提交——写配置要走校验和重载,每行失焦
|
||||
* 就提交会把几秒的等待塞进每一次输入。
|
||||
*
|
||||
* 值的往返规则:没动过的行原样保留(数组仍是数组);动过的行按显示文本
|
||||
* 重建,含逗号拆成数组,否则存字符串。
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||
import IconAdd from '~icons/ri/add-line'
|
||||
import IconClose from '~icons/ri/close-line'
|
||||
|
||||
const props = defineProps<{ open: boolean; hosts: ConfigObject }>()
|
||||
|
||||
const emit = defineEmits<{ close: []; save: [hosts: ConfigObject] }>()
|
||||
|
||||
interface Row {
|
||||
key: string
|
||||
value: string
|
||||
/** 值被编辑过。没动过的保存时用 original 原样写回 */
|
||||
dirty: boolean
|
||||
original: unknown
|
||||
}
|
||||
|
||||
const dialog = ref<HTMLDialogElement | null>(null)
|
||||
const rows = ref<Row[]>([])
|
||||
/** 保存被拦下的行号(键空着或和别的行重复) */
|
||||
const badRows = ref<Set<number>>(new Set())
|
||||
|
||||
function display(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (Array.isArray(value)) return value.map(String).join(', ')
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
rows.value = Object.entries(props.hosts).map(([key, value]) => ({
|
||||
key,
|
||||
value: display(value),
|
||||
dirty: false,
|
||||
original: value,
|
||||
}))
|
||||
badRows.value = new Set()
|
||||
dialog.value?.showModal()
|
||||
} else {
|
||||
dialog.value?.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function addRow() {
|
||||
rows.value = [...rows.value, { key: '', value: '', dirty: true, original: undefined }]
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
rows.value = rows.value.filter((_, i) => i !== index)
|
||||
badRows.value = new Set()
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const bad = new Set<number>()
|
||||
const seen = new Set<string>()
|
||||
const next: ConfigObject = {}
|
||||
for (const [index, row] of rows.value.entries()) {
|
||||
const key = row.key.trim()
|
||||
const raw = row.value.trim()
|
||||
if (key === '' && raw === '') continue
|
||||
if (key === '' || seen.has(key)) {
|
||||
bad.add(index)
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
if (!row.dirty && row.original !== undefined) {
|
||||
next[key] = row.original as ConfigObject[string]
|
||||
} else {
|
||||
next[key] = raw.includes(',')
|
||||
? raw.split(',').map((part) => part.trim()).filter(Boolean)
|
||||
: raw
|
||||
}
|
||||
}
|
||||
if (bad.size > 0) {
|
||||
badRows.value = bad
|
||||
return
|
||||
}
|
||||
emit('save', next)
|
||||
}
|
||||
|
||||
/** 点在遮罩上就关闭。内容区的点击 target 是里面的元素,到不了 dialog 本身。 */
|
||||
function onBackdrop(event: MouseEvent) {
|
||||
if (event.target === dialog.value) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialog"
|
||||
class="dlg"
|
||||
@click="onBackdrop"
|
||||
@close="emit('close')"
|
||||
@cancel.prevent="emit('close')"
|
||||
>
|
||||
<div class="body">
|
||||
<header class="head">
|
||||
<h2>追加 Hosts</h2>
|
||||
<button type="button" class="add" @click="addRow"><IconAdd /> 添加</button>
|
||||
</header>
|
||||
|
||||
<p v-if="rows.length === 0" class="faint">
|
||||
没有条目。hosts 把域名固定解析到指定的 IP 或别名,值里用逗号分隔可以写多个。
|
||||
</p>
|
||||
<div v-else class="list">
|
||||
<div v-for="(row, i) in rows" :key="i" :class="['hrow', badRows.has(i) && 'is-bad']">
|
||||
<input
|
||||
v-model="row.key"
|
||||
class="input mono"
|
||||
type="text"
|
||||
placeholder="域名,如 router.lan"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<input
|
||||
v-model="row.value"
|
||||
class="input mono grow"
|
||||
type="text"
|
||||
placeholder="IP 或别名,逗号分隔多个"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
@input="row.dirty = true"
|
||||
/>
|
||||
<button type="button" class="x" title="删除这一行" @click="removeRow(i)">
|
||||
<IconClose />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
<button type="button" class="btn" @click="emit('close')">取消</button>
|
||||
<button type="button" class="btn go" @click="submit">保存</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg {
|
||||
width: min(560px, calc(100vw - 2 * var(--s-5)));
|
||||
padding: 0;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.dlg::backdrop {
|
||||
background: rgb(0 0 0 / 0.45);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--s-5);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--s-4);
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
font-size: var(--t-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-soft);
|
||||
font-size: var(--t-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.add:hover {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.add svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.faint {
|
||||
color: var(--c-text-faint);
|
||||
font-size: var(--t-sm);
|
||||
line-height: var(--line);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
}
|
||||
|
||||
.hrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 180px;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-bg);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.is-bad .input {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
.x {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.x:hover {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-3);
|
||||
margin-top: var(--s-5);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--s-2) var(--s-4);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--c-surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.go {
|
||||
border-color: transparent;
|
||||
background: var(--c-accent);
|
||||
color: var(--c-bg);
|
||||
}
|
||||
.go:hover {
|
||||
border-color: transparent;
|
||||
color: var(--c-bg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
+1191
-246
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user