Compare commits
11
Commits
27ebdac034
..
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d2a3afff0 | ||
|
|
ea0efd4468 | ||
|
|
0f0ac3c4b7 | ||
|
|
23aa28fbea | ||
|
|
56546b20c5 | ||
|
|
b1b251b897 | ||
|
|
d6942c5702 | ||
|
|
b28fc572f7 | ||
|
|
5c924db235 | ||
|
|
fbb2a824dc | ||
|
|
944da9fee4 |
@@ -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"
|
||||
+6
-6
@@ -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:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。
|
||||
@@ -102,6 +102,6 @@ _Avoid_: sub-rules、子规则、私有规则
|
||||
Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。
|
||||
_Avoid_: 托管段落、受控段落
|
||||
|
||||
**默认路由**:
|
||||
默认入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
|
||||
_Avoid_: 主路由、根路由
|
||||
**legacy 路由**:
|
||||
legacy 入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
|
||||
_Avoid_: 默认路由、主路由、根路由
|
||||
|
||||
+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
|
||||
+2
-1
@@ -12,6 +12,7 @@ import {
|
||||
cpuUsageBetween,
|
||||
parseGateway,
|
||||
parseMemAvailable,
|
||||
parseMemTotal,
|
||||
parseNameservers,
|
||||
} from '../shared/host-info.ts'
|
||||
import type { CpuTimes, ExitProbe, HostInfo } from '../shared/host-info.ts'
|
||||
@@ -56,7 +57,7 @@ export async function hostInfo(): Promise<HostInfo> {
|
||||
return {
|
||||
cpuUsage,
|
||||
cpuCount: os.cpus().length,
|
||||
memTotal: os.totalmem(),
|
||||
memTotal: parseMemTotal(meminfo) ?? os.totalmem(),
|
||||
memAvailable: parseMemAvailable(meminfo) ?? os.freemem(),
|
||||
// loadavg 在 Windows 上恒为 0,那不是「负载为零」而是「没有这个概念」
|
||||
load1: process.platform === 'win32' ? null : load,
|
||||
|
||||
+7
-2
@@ -184,8 +184,13 @@ export async function updateGeoDatabases(env: Env): Promise<ValidateResult> {
|
||||
* 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于
|
||||
* 每隔一会儿就把连接掐断一次。
|
||||
*/
|
||||
export async function proxyGet(env: Env, path: string, stream = false): Promise<Response> {
|
||||
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(15_000) })
|
||||
export async function proxyGet(
|
||||
env: Env,
|
||||
path: string,
|
||||
stream = false,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<Response> {
|
||||
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(timeoutMs) })
|
||||
}
|
||||
|
||||
/** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */
|
||||
|
||||
+9
-1
@@ -366,7 +366,15 @@ function makeRoute(env: Env, token: string) {
|
||||
if (!allowed) {
|
||||
return json({ message: `不允许访问该路径:${target}` }, 403)
|
||||
}
|
||||
const upstream = await proxyGet(env, target + url.search, STREAM_PATHS.has(target))
|
||||
// 健康检查是同步的:内核测完才回话,一批 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' },
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('按入站聚合', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('默认入站用内核给的内部名,附上端口', () => {
|
||||
it('legacy 入站用内核给的内部名,附上端口', () => {
|
||||
const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })]
|
||||
expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)')
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface ConnectionMeta {
|
||||
host: string
|
||||
destinationIP: string
|
||||
destinationPort: string
|
||||
/** 从哪一路入站进来:命名入站是它的名字,默认入站是内核造的 DEFAULT-* */
|
||||
/** 从哪一路入站进来:命名入站是它的名字,legacy 入站是内核造的 DEFAULT-* */
|
||||
inboundName: string
|
||||
inboundPort: string
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function filterConnections(list: ConnectionItem[], query: string): Connec
|
||||
|
||||
/** 一路入站当前有多少条连接。 */
|
||||
export interface InboundUse {
|
||||
/** 命名入站显示名字;默认入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */
|
||||
/** 命名入站显示名字;legacy 入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */
|
||||
label: string
|
||||
count: number
|
||||
upload: number
|
||||
|
||||
@@ -3,9 +3,21 @@ 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'
|
||||
|
||||
+19
-3
@@ -11,6 +11,7 @@ export interface HostInfo {
|
||||
/** 0-100。第一次请求没有参照快照,是 null */
|
||||
cpuUsage: number | null
|
||||
cpuCount: number
|
||||
/** 内存总量。Linux 用 MemTotal,其余平台退回 totalmem */
|
||||
memTotal: number
|
||||
/** 还能用的内存。Linux 用 MemAvailable,其余平台退回 freemem */
|
||||
memAvailable: number
|
||||
@@ -37,13 +38,28 @@ export interface ExitProbe {
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** /proc/meminfo 里的 MemAvailable(内核估算的「还能用多少」),换算成字节。 */
|
||||
export function parseMemAvailable(meminfo: string): number | null {
|
||||
const match = /^MemAvailable:\s+(\d+)\s*kB/m.exec(meminfo)
|
||||
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 里的默认网关。
|
||||
*
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
proxyGroupName,
|
||||
pruneGenerated,
|
||||
readSetup,
|
||||
upgradeLegacyInbound,
|
||||
} from './inbound-groups.ts'
|
||||
import type { OutboundSetup } from './inbound-groups.ts'
|
||||
import { collectInbounds } from './inbounds.ts'
|
||||
@@ -114,6 +115,28 @@ describe('一路入站的三个组', () => {
|
||||
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)
|
||||
@@ -427,3 +450,73 @@ describe('附加规则', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
+75
-14
@@ -18,7 +18,7 @@
|
||||
* 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件)
|
||||
*/
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { resolveDestination } from './inbounds.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'
|
||||
@@ -390,19 +390,30 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
|
||||
|
||||
const { filter } = setup.proxy
|
||||
let groups = groupsOf(next)
|
||||
// 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 (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(
|
||||
@@ -433,6 +444,56 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
|
||||
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
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('合并两套写法的入站', () => {
|
||||
expect(list[1]!.listenerIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
|
||||
it('legacy 入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
|
||||
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' })
|
||||
})
|
||||
})
|
||||
|
||||
+143
-3
@@ -1,17 +1,28 @@
|
||||
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 } from './inbound-groups.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'
|
||||
@@ -77,7 +88,7 @@ describe('读出订阅列表', () => {
|
||||
})
|
||||
|
||||
describe('新增订阅', () => {
|
||||
it('自动生成缓存路径和默认间隔', () => {
|
||||
it('自动生成缓存路径、默认间隔和默认开启的健康检查', () => {
|
||||
const next = addSubscription({}, 'subs2', URL)
|
||||
expect(next['proxy-providers']).toEqual({
|
||||
subs2: {
|
||||
@@ -85,6 +96,7 @@ describe('新增订阅', () => {
|
||||
url: URL,
|
||||
path: './providers/subs2.yaml',
|
||||
interval: DEFAULT_INTERVAL,
|
||||
'health-check': DEFAULT_HEALTH_CHECK,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -106,11 +118,97 @@ describe('新增订阅', () => {
|
||||
it('从文件导入的订阅是 file 类型,没有地址和间隔', () => {
|
||||
const next = addFileSubscription({}, 'subs2')
|
||||
expect(next['proxy-providers']).toEqual({
|
||||
subs2: { type: 'file', path: './providers/subs2.yaml' },
|
||||
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)
|
||||
@@ -183,6 +281,48 @@ describe('删除前的引用检查', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
+109
-3
@@ -6,8 +6,8 @@
|
||||
* 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责,
|
||||
* 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。
|
||||
*
|
||||
* 订阅不提供改名:名字被策略组的 `use:` 和生成的副本引用,改名等于改一个被引用的
|
||||
* 标识符,要同步更新所有引用,漏一处就是断引用。删了重建更简单。
|
||||
* 改名必须走 renameSubscription:名字被策略组的 `use:` 和生成的副本引用,改名和
|
||||
* 同步引用要在同一次变换里完成,散开做漏一处就是断引用。
|
||||
*/
|
||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { isGenerated, providerCachePath } from './inbound-groups.ts'
|
||||
@@ -15,6 +15,19 @@ 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
|
||||
@@ -104,6 +117,7 @@ export function addSubscription(
|
||||
url,
|
||||
path: providerCachePath(name),
|
||||
...(interval !== null ? { interval } : {}),
|
||||
'health-check': { ...DEFAULT_HEALTH_CHECK },
|
||||
},
|
||||
}
|
||||
return next
|
||||
@@ -137,7 +151,11 @@ export function addFileSubscription(data: ConfigObject, name: string): ConfigObj
|
||||
const next = { ...data }
|
||||
next['proxy-providers'] = {
|
||||
...providersOf(data),
|
||||
[name]: { type: 'file', path: providerCachePath(name) },
|
||||
[name]: {
|
||||
type: 'file',
|
||||
path: providerCachePath(name),
|
||||
'health-check': { ...DEFAULT_HEALTH_CHECK },
|
||||
},
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -162,6 +180,94 @@ export function updateSubscription(
|
||||
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) }
|
||||
|
||||
+15
-5
@@ -53,7 +53,7 @@ function openInbound(id: string) {
|
||||
* 选中的那一路入站丢失,改完一个设置还要重新找回原来的位置。所以只有首次加载显示占位,
|
||||
* 之后都是原地更新。
|
||||
*/
|
||||
async function load() {
|
||||
async function load(): Promise<'ok' | 'unauthorized' | 'error'> {
|
||||
refreshing.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
@@ -64,22 +64,32 @@ async function load() {
|
||||
needsToken.value = false
|
||||
// 撤回/重做按钮的可用性跟着每次重读走
|
||||
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 {
|
||||
refreshing.value = false
|
||||
booted.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function submitToken(value: string) {
|
||||
/**
|
||||
* 提交登录页填的令牌。
|
||||
*
|
||||
* 401 必须当场说一声:登录页在提交前后都是它自己,组件不重建、输入框也不清空,
|
||||
* 不提示的话点了「进入」界面一个字都不变,看着像没反应。
|
||||
* 只在这里提示,不在 load() 里——存着的令牌失效时弹回登录页,页面本身就是说明。
|
||||
*/
|
||||
async function submitToken(value: string) {
|
||||
setToken(value)
|
||||
void load()
|
||||
// 令牌在哪儿取,登录页上就写着,不必在提示里再说一遍
|
||||
if ((await load()) === 'unauthorized') toast('令牌不正确', { kind: 'error' })
|
||||
}
|
||||
|
||||
function logout() {
|
||||
|
||||
+13
@@ -310,6 +310,19 @@ export const api = {
|
||||
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`,
|
||||
),
|
||||
|
||||
/**
|
||||
* 每个规则组的运行时状态:装了多少条规则、上次什么时候更新。
|
||||
* 配置文件里只有声明,这两个数字只有运行中的内核知道。
|
||||
|
||||
@@ -45,7 +45,7 @@ function outboundOf(inbound: Inbound): { text: string; chained: boolean } {
|
||||
<span class="type mono">{{ inboundTypeLabel(inbound.type) }}</span>
|
||||
<span class="name">
|
||||
{{ inbound.name }}
|
||||
<span v-if="inbound.isDefault" class="tag">默认</span>
|
||||
<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="经过链式代理">
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间、健康检查开没开。
|
||||
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间,末尾是调顺序的两个箭头。
|
||||
*
|
||||
* 只管显示和选中,不碰任何数据。
|
||||
* 只管显示、选中和「往哪边挪一位」,不碰任何数据。
|
||||
*/
|
||||
import IconUp from '~icons/ri/arrow-up-line'
|
||||
import IconDown from '~icons/ri/arrow-down-line'
|
||||
|
||||
defineProps<{
|
||||
/** 一行要显示的东西,由页面按配置和运行时数据拼好 */
|
||||
@@ -13,12 +15,15 @@ defineProps<{
|
||||
lines: number | null
|
||||
/** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */
|
||||
updated: Date | null
|
||||
/** 健康检查开没开 */
|
||||
healthCheck: boolean
|
||||
}>
|
||||
/** 配置正在保存。调顺序要写配置文件,保存期间不能再点 */
|
||||
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 {
|
||||
@@ -28,22 +33,37 @@ function formatUpdated(at: Date): string {
|
||||
|
||||
<template>
|
||||
<ol class="rows">
|
||||
<li v-for="row in rows" :key="row.name">
|
||||
<button
|
||||
type="button"
|
||||
:class="['line', selected === row.name && 'is-on']"
|
||||
@click="selected = row.name"
|
||||
>
|
||||
<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>
|
||||
<span :class="['hc', !row.healthCheck && 'is-off']">
|
||||
{{ row.healthCheck ? '健康检查' : '—' }}
|
||||
</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>
|
||||
@@ -63,27 +83,34 @@ function formatUpdated(at: Date): string {
|
||||
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 {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 4rem 6.5rem 4.5rem;
|
||||
grid-template-columns: minmax(0, 1fr) 4rem 6.5rem;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-3) var(--s-4);
|
||||
padding: var(--s-3) var(--s-2) 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);
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -99,13 +126,38 @@ function formatUpdated(at: Date): string {
|
||||
color: var(--c-text-soft);
|
||||
}
|
||||
|
||||
.hc {
|
||||
text-align: right;
|
||||
font-size: var(--t-xs);
|
||||
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;
|
||||
}
|
||||
|
||||
.hc.is-off {
|
||||
.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>
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ export function modeLabel(mode: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个监听中的代理端口。name 用配置键原词,和路由页默认入站的命名一致。 */
|
||||
/** 一个监听中的代理端口。name 用配置键原词,和路由页legacy 入站的命名一致。 */
|
||||
export interface KernelPort {
|
||||
name: string
|
||||
port: number
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 |
@@ -13,8 +13,11 @@ import {
|
||||
addFileSubscription,
|
||||
addSubscription,
|
||||
listSubscriptions,
|
||||
moveSubscription,
|
||||
providerNames,
|
||||
removeSubscription,
|
||||
renameSubscription,
|
||||
setSubscriptionHealthCheck,
|
||||
subscriptionPathConflict,
|
||||
subscriptionReferences,
|
||||
updateSubscription,
|
||||
@@ -48,6 +51,8 @@ const formEditing = ref<Subscription | null>(null)
|
||||
const removing = ref<Subscription | null>(null)
|
||||
/** 正在等内核重新拉取的订阅名 */
|
||||
const refreshing = ref<string | null>(null)
|
||||
/** 正在等内核测完线路的订阅名 */
|
||||
const checking = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 每个订阅的运行时数据:当前线路、上次更新时间。
|
||||
@@ -103,7 +108,6 @@ const rows = computed(() =>
|
||||
name: s.name,
|
||||
lines: info ? info.proxies.length : null,
|
||||
updated: parseUpdated(info?.updatedAt),
|
||||
healthCheck: s.healthCheck !== null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -149,7 +153,8 @@ async function submitForm(draft: {
|
||||
}) {
|
||||
const editing = formEditing.value
|
||||
|
||||
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦(编辑不改名,只查新增)
|
||||
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦
|
||||
// (只查新增:改名不动缓存路径,不会制造新的撞车)
|
||||
if (!editing) {
|
||||
const path = providerCachePath(draft.name)
|
||||
const clash = subscriptionPathConflict(props.data, draft.name, path)
|
||||
@@ -173,8 +178,17 @@ async function submitForm(draft: {
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑时先改名再改字段。名字没动时 renameSubscription 原样返回;
|
||||
// file 类型只有名称可改,不能走 updateSubscription——它会写进 url
|
||||
const next = editing
|
||||
? updateSubscription(clone(), editing.name, draft.url, draft.interval)
|
||||
? 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)
|
||||
@@ -182,7 +196,8 @@ async function submitForm(draft: {
|
||||
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
|
||||
if (!ok) return
|
||||
formOpen.value = false
|
||||
if (!editing) selectedName.value = draft.name
|
||||
// 新增和改名都要把选中跟过去,否则选中名失效会被重置到第一行
|
||||
selectedName.value = draft.name
|
||||
}
|
||||
|
||||
function askRemove() {
|
||||
@@ -205,6 +220,17 @@ async function removeConfirmed() {
|
||||
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
|
||||
@@ -224,6 +250,39 @@ async function refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 让内核立刻测一遍选中订阅的线路。运行时动作,不写配置文件,所以不走 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)
|
||||
|
||||
/** 挨个让内核重新拉取所有订阅。失败的点名,成功的不逐个报。 */
|
||||
@@ -278,7 +337,12 @@ async function refreshAll() {
|
||||
</header>
|
||||
|
||||
<div class="split">
|
||||
<ProviderList v-model:selected="selectedName" :rows="rows" />
|
||||
<ProviderList
|
||||
v-model:selected="selectedName"
|
||||
:rows="rows"
|
||||
:saving="saving"
|
||||
@move="move"
|
||||
/>
|
||||
|
||||
<SubscriptionDetail
|
||||
v-if="selected"
|
||||
@@ -286,10 +350,13 @@ async function refreshAll() {
|
||||
: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>
|
||||
|
||||
|
||||
+34
-12
@@ -32,6 +32,7 @@ import OutboundCard from '../components/OutboundCard.vue'
|
||||
import SourcePicker from '../components/SourcePicker.vue'
|
||||
import IconEdit from '~icons/ri/edit-line'
|
||||
import IconDelete from '~icons/ri/delete-bin-line'
|
||||
import IconUpgrade from '~icons/ri/arrow-up-circle-line'
|
||||
import IconRight from '~icons/ri/arrow-right-s-line'
|
||||
import IconOffline from '~icons/ri/cloud-off-line'
|
||||
import IconAdd from '~icons/ri/add-line'
|
||||
@@ -61,6 +62,8 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
edit: []
|
||||
remove: []
|
||||
/** 把 legacy 入站升级成命名入站。写配置文件,由父组件先弹确认 */
|
||||
upgrade: []
|
||||
/** 切换某个策略组当前选中谁 */
|
||||
select: [group: string, member: string]
|
||||
test: [name: string, provider: string]
|
||||
@@ -90,7 +93,7 @@ const lanMissing = computed(() => missingLanRules(props.subRules).length)
|
||||
*/
|
||||
const subMatchAt = computed(() => firstMatchIndex(props.subRules))
|
||||
|
||||
/** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */
|
||||
/** legacy 入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删,只能升级。 */
|
||||
const locked = computed(() => props.inbound.isDefault)
|
||||
|
||||
const activeTab = ref('')
|
||||
@@ -282,7 +285,9 @@ watch([() => props.inbound.id, () => JSON.stringify(props.setup)], reset, { imme
|
||||
const chainOn = computed({
|
||||
get: () => form.value.chain !== null,
|
||||
set: (on: boolean) => {
|
||||
form.value.chain = on ? { sources: [...props.subscriptions], filter: null } : null
|
||||
// 入口订阅不预设:入口是「先落到哪一跳」,用户心里有具体的那一条,
|
||||
// 把订阅全给上只是让他保存前先去取消勾选
|
||||
form.value.chain = on ? { sources: [], filter: null } : null
|
||||
},
|
||||
})
|
||||
|
||||
@@ -423,9 +428,15 @@ const flowPreview = computed(
|
||||
pendingEntry.value !== null,
|
||||
)
|
||||
/**
|
||||
* 至少要选一个订阅。AUTO 和 FALLBACK 只有 `use:` 没有点名成员,一个订阅都不选就是空组,
|
||||
* 内核会直接拒绝整份配置(内核源码 adapter/outboundgroup/parser.go:117-119)。
|
||||
* 在这里拦住,比让用户按下保存再读一段内核报错好。
|
||||
* 两边都至少要选一个订阅。
|
||||
*
|
||||
* 入口组只有 `use:` 没有点名成员,一个订阅都不选就是空组,内核会直接拒绝整份配置
|
||||
* (内核源码 adapter/outboundgroup/parser.go:117-119)。在这里拦住,比让用户按下
|
||||
* 保存再读一段内核报错好。
|
||||
*
|
||||
* 代理这边空来源是能落盘的(applySetup 只建 SELECT、成员只有 DIRECT,新增入站就停在
|
||||
* 这个状态),但那是「还没配」的起点,不该是取消勾选保存出来的结果——一路已经在走代理
|
||||
* 的入站不能这么悄悄变成直连。
|
||||
*/
|
||||
const filled = (spec: { sources: string[] }) => spec.sources.length > 0
|
||||
const complete = computed(
|
||||
@@ -448,10 +459,15 @@ const complete = computed(
|
||||
<div class="title">
|
||||
<span class="port mono">{{ inbound.port }}</span>
|
||||
<span class="name">{{ inbound.name }}</span>
|
||||
<div v-if="!locked" class="acts">
|
||||
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
|
||||
<button type="button" class="danger" @click="emit('remove')">
|
||||
<IconDelete /> 删除
|
||||
<div class="acts">
|
||||
<template v-if="!locked">
|
||||
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
|
||||
<button type="button" class="danger" @click="emit('remove')">
|
||||
<IconDelete /> 删除
|
||||
</button>
|
||||
</template>
|
||||
<button v-else type="button" :disabled="saving" @click="emit('upgrade')">
|
||||
<IconUpgrade /> 改为命名入站
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,7 +594,8 @@ const complete = computed(
|
||||
</h3>
|
||||
|
||||
<!-- 草稿态:显示保存后的成员。现有成员点了立刻生效,保存后才加入的点了是预选 -->
|
||||
<div v-if="chainDraft" class="grid">
|
||||
<p v-if="chainDraft && form.chain.sources.length === 0" class="hint">还没有选订阅</p>
|
||||
<div v-else-if="chainDraft" class="grid">
|
||||
<OutboundCard
|
||||
v-for="card in chainCards"
|
||||
:key="card.name"
|
||||
@@ -842,16 +859,21 @@ const complete = computed(
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acts button:hover {
|
||||
.acts button:hover:not(:disabled) {
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
}
|
||||
|
||||
.acts button.danger:hover {
|
||||
.acts button.danger:hover:not(:disabled) {
|
||||
border-color: var(--c-danger);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.acts button:disabled {
|
||||
color: var(--c-text-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
sharedPeers,
|
||||
syncGenerated,
|
||||
readSetup,
|
||||
upgradeLegacyInbound,
|
||||
userProviders,
|
||||
} from '@shared/inbound-groups.ts'
|
||||
import { missingLanRules, moveRule, ruleProvidersOf } from '@shared/rules.ts'
|
||||
@@ -53,12 +54,22 @@ const emit = defineEmits<{ saved: []; 'focus-done': [] }>()
|
||||
const inbounds = computed(() => collectInbounds(props.data))
|
||||
const subscriptions = computed(() => userProviders(props.data))
|
||||
|
||||
/** 刚接管一路入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。 */
|
||||
/**
|
||||
* 接管一路已有入站时给它什么:订阅全给上,不筛。三个组由 applySetup 一并建出来。
|
||||
* 来源不留空,是因为这一路本来就在把流量送到某个出站上,接管不该顺手把它改成直连。
|
||||
*/
|
||||
const defaultSetup = (): OutboundSetup => ({
|
||||
proxy: { sources: [...subscriptions.value], filter: null },
|
||||
chain: null,
|
||||
})
|
||||
|
||||
/**
|
||||
* 新增入站时给它什么:一个订阅都不预设。全新的端口还没有人在用,替用户挑一批订阅
|
||||
* 只会让他保存完再回来取消勾选。来源为空的专属组只有 DIRECT(见 applySetup),
|
||||
* 用户在详情页的配置里选好订阅再保存。
|
||||
*/
|
||||
const emptySetup = (): OutboundSetup => ({ proxy: { sources: [], filter: null }, chain: null })
|
||||
|
||||
const selectedId = ref<string | null>(null)
|
||||
const saving = ref(false)
|
||||
const fields = ref<ListenerFields | null>(null)
|
||||
@@ -177,6 +188,30 @@ async function adopt() {
|
||||
await commit(applySetup(clone(), target.name, defaultSetup()), `${target.name} 已改为逐条选线路`)
|
||||
}
|
||||
|
||||
/** 待确认升级的那一路 legacy 入站。非 null 时确认对话框打开着。 */
|
||||
const upgrading = ref<Inbound | null>(null)
|
||||
|
||||
function askUpgrade() {
|
||||
const target = selected.value
|
||||
if (!target || target.legacyKey === null || saving.value) return
|
||||
upgrading.value = target
|
||||
}
|
||||
|
||||
async function upgradeConfirmed() {
|
||||
const target = upgrading.value
|
||||
upgrading.value = null
|
||||
if (!target || saving.value) return
|
||||
const next = upgradeLegacyInbound(clone(), target)
|
||||
if (!next) {
|
||||
toast(`已有名为 ${target.name} 的入站,无法升级`, { kind: 'error' })
|
||||
return
|
||||
}
|
||||
// 新入站追加在 listeners 末尾,升级成功后把选中跟过去,否则选中会被重置到第一行
|
||||
const index = Array.isArray(props.data.listeners) ? props.data.listeners.length : 0
|
||||
const ok = await commit(next, `${target.name} 已改为命名入站`)
|
||||
if (ok) selectedId.value = `listener:${String(index)}`
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
formEditing.value = null
|
||||
formOpen.value = true
|
||||
@@ -200,7 +235,7 @@ async function submitForm(entry: ConfigObject) {
|
||||
// 新增的入站直接进专属组模式。否则新建完看到的是一块「这一路不由 Patchbay 管理」
|
||||
// 的提示,还得再点一次才能用。
|
||||
const name = String(entry.name)
|
||||
const withGroup = editingIndex === null ? applySetup(next, name, defaultSetup()) : next
|
||||
const withGroup = editingIndex === null ? applySetup(next, name, emptySetup()) : next
|
||||
|
||||
const ok = await commit(withGroup, editingIndex === null ? `已新增 ${name}` : '已保存')
|
||||
// 失败时不能关对话框——关了再打开就是空表单,用户填的名称、端口、类型全没了
|
||||
@@ -368,6 +403,7 @@ async function removeConfirmed() {
|
||||
:saving="saving"
|
||||
@edit="openEdit"
|
||||
@remove="askRemove"
|
||||
@upgrade="askUpgrade"
|
||||
@select="selectMember"
|
||||
@test="test"
|
||||
@save="saveSetup"
|
||||
@@ -400,6 +436,19 @@ async function removeConfirmed() {
|
||||
@confirm="removeConfirmed"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="upgrading !== null"
|
||||
:message="
|
||||
upgrading
|
||||
? `将「${upgrading.name}」改为命名入站?端口不变,此后它的流量改查自己的附加规则` +
|
||||
`(全局规则中 MATCH 之前的部分会复制过来),可以单独选订阅和线路。`
|
||||
: ''
|
||||
"
|
||||
action="升级"
|
||||
@close="upgrading = null"
|
||||
@confirm="upgradeConfirmed"
|
||||
/>
|
||||
|
||||
<RuleForm
|
||||
:open="subFormOpen"
|
||||
:editing="subEditing === null ? null : { index: subEditing, raw: subRules[subEditing]! }"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 选中那个订阅的详情:地址、更新间隔、健康检查,和它带来的线路。
|
||||
* 选中那个订阅的详情:地址、更新间隔,和它带来的线路。
|
||||
*
|
||||
* 健康检查不在字段区展示,只是右上角一个开关动作:默认就是开着的,
|
||||
* 值得占一行的只有「被谁关掉了」这件事,而按钮文案本身已经说明了状态。
|
||||
*
|
||||
* 线路列表只读。这些线路不在内核的全局名字表里,不能被入站直接引用,
|
||||
* 只能经策略组的 `use:` 整批引入,所以这里没有「换线」可点。
|
||||
@@ -13,6 +16,9 @@ import IconEdit from '~icons/ri/edit-line'
|
||||
import IconDelete from '~icons/ri/delete-bin-line'
|
||||
import IconRefresh from '~icons/ri/refresh-line'
|
||||
import IconOffline from '~icons/ri/cloud-off-line'
|
||||
import IconPulse from '~icons/ri/pulse-line'
|
||||
import IconSync from '~icons/ri/loop-right-line'
|
||||
import IconLoad from '~icons/ri/loader-4-line'
|
||||
|
||||
const props = defineProps<{
|
||||
subscription: Subscription
|
||||
@@ -21,6 +27,8 @@ const props = defineProps<{
|
||||
online: boolean
|
||||
/** 正在等内核重新拉取 */
|
||||
refreshing: boolean
|
||||
/** 正在等内核测完这个订阅的线路 */
|
||||
checking: boolean
|
||||
/** 配置正在保存 */
|
||||
saving: boolean
|
||||
}>()
|
||||
@@ -30,6 +38,10 @@ const emit = defineEmits<{
|
||||
remove: []
|
||||
/** 让内核立刻重新拉取。运行时动作,不写配置文件 */
|
||||
refresh: []
|
||||
/** 让内核立刻测一遍这个订阅的线路。运行时动作,不写配置文件 */
|
||||
check: []
|
||||
/** 开关健康检查。写配置文件,走保存链路 */
|
||||
toggleHealthCheck: []
|
||||
}>()
|
||||
|
||||
const lines = computed(() => props.info?.proxies ?? [])
|
||||
@@ -48,15 +60,13 @@ function delayClass(ms: number | null): string {
|
||||
<div class="title">
|
||||
<span class="name">{{ subscription.name }}</span>
|
||||
<div class="acts">
|
||||
<button type="button" :disabled="saving" @click="emit('toggleHealthCheck')">
|
||||
<IconPulse /> {{ subscription.healthCheck ? '关闭健康检查' : '开启健康检查' }}
|
||||
</button>
|
||||
<button type="button" :disabled="!online || refreshing" @click="emit('refresh')">
|
||||
<IconRefresh /> {{ refreshing ? '更新中' : '更新订阅' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="saving || subscription.type === 'file'"
|
||||
:title="subscription.type === 'file' ? '文件导入的订阅没有可编辑的字段' : undefined"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
<button type="button" :disabled="saving" @click="emit('edit')">
|
||||
<IconEdit /> 编辑
|
||||
</button>
|
||||
<button type="button" class="danger" :disabled="saving" @click="emit('remove')">
|
||||
@@ -78,19 +88,23 @@ function delayClass(ms: number | null): string {
|
||||
{{ subscription.interval !== null ? `每 ${subscription.interval} 秒` : '已关闭' }}
|
||||
</dd>
|
||||
</template>
|
||||
<dt>健康检查</dt>
|
||||
<dd class="mono">
|
||||
<template v-if="subscription.healthCheck">
|
||||
{{ subscription.healthCheck.url
|
||||
}}{{ subscription.healthCheck.interval !== null ? `,每 ${subscription.healthCheck.interval} 秒` : '' }}
|
||||
</template>
|
||||
<template v-else>未开启</template>
|
||||
</dd>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<section class="block">
|
||||
<h3>线路</h3>
|
||||
<h3>
|
||||
线路
|
||||
<button
|
||||
type="button"
|
||||
class="check"
|
||||
:title="checking ? '检查中' : '检查全部线路的延迟'"
|
||||
:disabled="!online || checking || lines.length === 0"
|
||||
@click="emit('check')"
|
||||
>
|
||||
<IconLoad v-if="checking" class="spin" />
|
||||
<IconSync v-else />
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
<p v-if="!online" class="hint"><IconOffline /> 内核未连接</p>
|
||||
<p v-else-if="lines.length === 0" class="hint">这个订阅现在没有线路</p>
|
||||
@@ -199,6 +213,8 @@ function delayClass(ms: number | null): string {
|
||||
}
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
@@ -206,6 +222,38 @@ h3 {
|
||||
margin-bottom: var(--s-3);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
}
|
||||
.check:hover:not(:disabled) {
|
||||
background: var(--c-surface-sunken);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.check:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -221,6 +269,12 @@ h3 {
|
||||
padding: 0;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
/* 细杆加透明轨道,别让系统滚动条压在延迟列上;gutter 常驻,
|
||||
线路数跨过滚动阈值时右缘不横跳 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-border-strong) transparent;
|
||||
scrollbar-gutter: stable;
|
||||
padding-right: var(--s-2);
|
||||
}
|
||||
|
||||
.row {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* 新增或编辑一个订阅。
|
||||
*
|
||||
* 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成,
|
||||
* 让用户填只会填出和链式代理对不上的路径。订阅不能改名——名字被策略组的 `use:`
|
||||
* 和生成的副本引用,改名要同步所有引用,所以编辑时名字只是静态展示,想换名就删了重建。
|
||||
* 让用户填只会填出和链式代理对不上的路径。编辑时名字也能改,引用同步和
|
||||
* 副本重建由保存链路负责(renameSubscription + syncGenerated)。
|
||||
*
|
||||
* 新增时地址之外还能从文件导入:选了文件就是 file 类型的订阅,没有地址也没有
|
||||
* 自动更新(内核靠文件监视跟进变化),相关字段随之收起。
|
||||
@@ -28,7 +28,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
/** 编辑时 name 是被编辑那个订阅的原名。file 非 null 表示从文件导入 */
|
||||
/** 编辑时 name 和原名不同就是改名。file 非 null 表示从文件导入 */
|
||||
submit: [
|
||||
draft: {
|
||||
name: string
|
||||
@@ -66,6 +66,8 @@ watch(
|
||||
const nameError = computed(() => {
|
||||
const n = name.value.trim()
|
||||
if (n === '') return ''
|
||||
// 编辑时名字没动不算重名,takenNames 里有它自己
|
||||
if (n === props.editing?.name) return ''
|
||||
// 带这个前缀的会被当成 Patchbay 生成物:列表里不显示,pruneGenerated 还会把它清掉
|
||||
if (n.startsWith(GENERATED_PREFIX)) return `名称不能以 ${GENERATED_PREFIX} 开头`
|
||||
if (props.takenNames.includes(n)) return '名称已存在'
|
||||
@@ -80,8 +82,9 @@ const intervalInvalid = computed(() => {
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (props.saving) return false
|
||||
if (props.editing === null && (name.value.trim() === '' || nameError.value !== '')) return false
|
||||
if (name.value.trim() === '' || nameError.value !== '') return false
|
||||
if (file.value !== null) return true
|
||||
if (props.editing?.type === 'file') return true
|
||||
return url.value.trim() !== '' && !intervalInvalid.value
|
||||
})
|
||||
|
||||
@@ -92,7 +95,7 @@ function onFile(fileName: string, content: string) {
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
emit('submit', {
|
||||
name: props.editing?.name ?? name.value.trim(),
|
||||
name: name.value.trim(),
|
||||
url: url.value.trim(),
|
||||
interval: autoUpdate.value ? Number(interval.value.trim()) : null,
|
||||
file: file.value,
|
||||
@@ -130,7 +133,7 @@ function onBackdrop(event: MouseEvent) {
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<label v-if="!editing" class="row">
|
||||
<label class="row">
|
||||
<span class="k">名称</span>
|
||||
<span class="v">
|
||||
<input v-model="name" type="text" placeholder="subs" autocomplete="off" />
|
||||
@@ -138,7 +141,8 @@ function onBackdrop(event: MouseEvent) {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<template v-if="file === null">
|
||||
<!-- file 类型没有地址和自动更新,编辑它只有名称可改 -->
|
||||
<template v-if="file === null && editing?.type !== 'file'">
|
||||
<label class="row">
|
||||
<span class="k">地址</span>
|
||||
<span class="v">
|
||||
|
||||
Reference in New Issue
Block a user