4 Commits
Author SHA1 Message Date
im0o b1b251b897 legacy 入站可升级为命名入站
release / release (push) Successful in 26s
顶层端口字段没有 proxy 可写(listener/inbound/base.go:97),界面上
切不了订阅。升级删掉顶层键、建同端口的 listener:listen 按 allow-lan
推导后写死,全局规则里 MATCH 之前的部分复制成附加规则,来源照抄
MATCH 目标组的 use:,让流量去向尽量不变。udp 不写,命名入站解析时
默认就是开的(listener/parse.go:52)。
「默认」tag 随术语表改成 legacy,旧叫法进 avoid 列表。
2026-08-12 17:30:47 +08:00
im0o d6942c5702 订阅健康检查默认开启,编辑支持改名
内核没有默认的健康检查地址,url 为空时检查根本不会运行
(adapter/provider/healthcheck.go:196-199),所以默认值必须写全;
开关只翻 enable,手写的 url/interval 留着下次开启时还用。
改名和策略组 use: 的引用同步收在同一次变换里,链式副本由保存
链路的 syncGenerated 按新名字重建,缓存路径刻意不动——file 类型
的数据本体和副本认亲都靠它。
2026-08-12 17:30:07 +08:00
im0o b28fc572f7 主机内存总量改从 /proc/meminfo 读
os.totalmem() 底层是 sysinfo() 系统调用,LXC 里 lxcfs 只虚拟化 /proc
文件、拦不住系统调用,导致容器里显示的是宿主机的物理内存总量。
从 /proc/meminfo 读 MemTotal 才是容器限额,且与 MemAvailable 同源。
2026-08-12 16:38:19 +08:00
im0o 5c924db235 最新版查询改走 API:这台 Gitea 没有 latest/download 路由
/releases/latest/download/<asset> 实测 404,只有带具体 tag 的
下载路径可用。版本号从 /api/v1/.../releases/latest 拿,
version.txt 因此不再需要单独作为 asset 上传。
2026-08-12 15:57:45 +08:00
21 changed files with 478 additions and 93 deletions
+3 -8
View File
@@ -32,11 +32,8 @@ jobs:
bun build --target=bun --minify server/index.ts --outfile dist/patchbay/server/index.js bun build --target=bun --minify server/index.ts --outfile dist/patchbay/server/index.js
cp -r web-dist dist/patchbay/web-dist cp -r web-dist dist/patchbay/web-dist
echo "${GITHUB_REF_NAME}" > dist/patchbay/version.txt echo "${GITHUB_REF_NAME}" > dist/patchbay/version.txt
cp dist/patchbay/version.txt version.txt
tar -czf patchbay.tar.gz -C dist patchbay tar -czf patchbay.tar.gz -C dist patchbay
# version.txt 单独作为 asset 再传一份,install.sh check 用它比对版本,
# 不用把整个 tarball 拉下来。
# 直接 curl Gitea 的 API 而不用现成 action:gitea.com/actions/release-action # 直接 curl Gitea 的 API 而不用现成 action:gitea.com/actions/release-action
# 是 Go 程序,runner 镜像里没有 Go 工具链,跑不起来 # 是 Go 程序,runner 镜像里没有 Go 工具链,跑不起来
- name: 发布 Release - name: 发布 Release
@@ -55,8 +52,6 @@ jobs:
# 响应里第一个 "id" 就是 release 自己的(嵌套对象如 author 排在它后面) # 响应里第一个 "id" 就是 release 自己的(嵌套对象如 author 排在它后面)
id="$(printf '%s' "$resp" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)" id="$(printf '%s' "$resp" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)"
[ -n "$id" ] || { echo "没拿到 release id,响应: $resp"; exit 1; } [ -n "$id" ] || { echo "没拿到 release id,响应: $resp"; exit 1; }
for f in patchbay.tar.gz version.txt; do curl -fsS -X POST -H "$auth" -F "attachment=@patchbay.tar.gz" \
curl -fsS -X POST -H "$auth" -F "attachment=@${f}" \ "$api/releases/${id}/assets?name=patchbay.tar.gz" >/dev/null
"$api/releases/${id}/assets?name=${f}" >/dev/null echo "已上传 patchbay.tar.gz"
echo "已上传 $f"
done
+6 -6
View File
@@ -10,9 +10,9 @@
mihomo 上的一个监听端口,客户端连上它把流量交进来。 mihomo 上的一个监听端口,客户端连上它把流量交进来。
_Avoid_: inbound、监听器、listener、进站 _Avoid_: inbound、监听器、listener、进站
**默认入站**: **legacy 入站**:
由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除。 由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除,可升级为命名入站。
_Avoid_: 主端口、老式端口、默认端口 _Avoid_: 默认入站、主端口、老式端口、默认端口
**命名入站**: **命名入站**:
由 `listeners:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。 由 `listeners:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。
@@ -102,6 +102,6 @@ _Avoid_: sub-rules、子规则、私有规则
Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。 Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。
_Avoid_: 托管段落、受控段落 _Avoid_: 托管段落、受控段落
**默认路由**: **legacy 路由**:
默认入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。 legacy 入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
_Avoid_: 主路由、根路由 _Avoid_: 默认路由、主路由、根路由
+12 -12
View File
@@ -21,6 +21,7 @@ set -Eeuo pipefail
SELF_VERSION="1.0.0" SELF_VERSION="1.0.0"
REPO_URL="https://git.im0o.top/im0o/mihomo-patchbay" 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 版本钉死,避免运行时行为漂移;和 .gitea/workflows/release.yml 的构建版本一起改
BUN_VERSION="${PATCHBAY_BUN_VERSION:-1.3.14}" BUN_VERSION="${PATCHBAY_BUN_VERSION:-1.3.14}"
@@ -108,12 +109,10 @@ gh_url() { # gh_url "owner/repo/releases/..." -> 完整
printf '%s' "$u" printf '%s' "$u"
} }
release_url() { # release_url <asset> -> Gitea Release 下载地址 # 这台 Gitea 的网页路由没有 /releases/latest/download/<asset> 这种别名(实测 404),
if [[ -n "$WANT_VERSION" ]]; then # 所以下载地址必须带具体版本号,「最新」由 latest_version 先问 API
printf '%s/releases/download/%s/%s' "$REPO_URL" "$WANT_VERSION" "$1" release_url() { # release_url <版本> <asset> -> 下载地址
else printf '%s/releases/download/%s/%s' "$REPO_URL" "$1" "$2"
printf '%s/releases/latest/download/%s' "$REPO_URL" "$1"
fi
} }
# 读 config.yaml 顶层的简单键(形如 `secret: "xxx"`),套路取自 mihomo-up.sh 的 cfg_get # 读 config.yaml 顶层的简单键(形如 `secret: "xxx"`),套路取自 mihomo-up.sh 的 cfg_get
@@ -129,11 +128,12 @@ installed_version() {
} }
latest_version() { latest_version() {
local v local resp v
v="$(curl -fsSL --retry 2 --connect-timeout 5 --max-time 15 "$(release_url version.txt)" \ resp="$(curl -fsSL --retry 2 --connect-timeout 5 --max-time 15 "$API_URL/releases/latest")" \
| tr -d '[:space:]')" \ || die "查询最新 Release 失败: $API_URL/releases/latest(还没发布过 Release?网络不通?)"
|| die "拉取版本号失败: $(release_url version.txt)(仓库还没发布过 Release?网络不通?)" # 响应里 tag_name 只出现一次(release 对象自己的),不装 jq 也能取
[[ "$v" =~ ^v[0-9]+\.[0-9]+ ]] || die "拿到的版本号格式不对: $v" v="$(printf '%s' "$resp" | grep -o '"tag_name":"[^"]*"' | head -n1 | cut -d'"' -f4)"
[[ "$v" =~ ^v[0-9]+\.[0-9]+ ]] || die "拿到的版本号格式不对: ${v:-空}"
printf '%s' "$v" printf '%s' "$v"
} }
@@ -181,7 +181,7 @@ install_bun() {
# ───────────────────────── 应用本体 ───────────────────────── # ───────────────────────── 应用本体 ─────────────────────────
install_app() { # $1 = 目标版本 install_app() { # $1 = 目标版本
local target="$1" url local target="$1" url
url="$(release_url patchbay.tar.gz)" url="$(release_url "$target" patchbay.tar.gz)"
info "下载 patchbay.tar.gz" info "下载 patchbay.tar.gz"
curl -fL -# --retry 3 --connect-timeout 15 --max-time 300 -o "$TMPDIR_/app.tar.gz" "$url" \ curl -fL -# --retry 3 --connect-timeout 15 --max-time 300 -o "$TMPDIR_/app.tar.gz" "$url" \
+2 -1
View File
@@ -12,6 +12,7 @@ import {
cpuUsageBetween, cpuUsageBetween,
parseGateway, parseGateway,
parseMemAvailable, parseMemAvailable,
parseMemTotal,
parseNameservers, parseNameservers,
} from '../shared/host-info.ts' } from '../shared/host-info.ts'
import type { CpuTimes, ExitProbe, HostInfo } 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 { return {
cpuUsage, cpuUsage,
cpuCount: os.cpus().length, cpuCount: os.cpus().length,
memTotal: os.totalmem(), memTotal: parseMemTotal(meminfo) ?? os.totalmem(),
memAvailable: parseMemAvailable(meminfo) ?? os.freemem(), memAvailable: parseMemAvailable(meminfo) ?? os.freemem(),
// loadavg 在 Windows 上恒为 0,那不是「负载为零」而是「没有这个概念」 // loadavg 在 Windows 上恒为 0,那不是「负载为零」而是「没有这个概念」
load1: process.platform === 'win32' ? null : load, load1: process.platform === 'win32' ? null : load,
+1 -1
View File
@@ -159,7 +159,7 @@ describe('按入站聚合', () => {
]) ])
}) })
it('默认入站用内核给的内部名,附上端口', () => { it('legacy 入站用内核给的内部名,附上端口', () => {
const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })] const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })]
expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)') expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)')
}) })
+2 -2
View File
@@ -10,7 +10,7 @@ export interface ConnectionMeta {
host: string host: string
destinationIP: string destinationIP: string
destinationPort: string destinationPort: string
/** 从哪一路入站进来:命名入站是它的名字,默认入站是内核造的 DEFAULT-* */ /** 从哪一路入站进来:命名入站是它的名字,legacy 入站是内核造的 DEFAULT-* */
inboundName: string inboundName: string
inboundPort: string inboundPort: string
} }
@@ -100,7 +100,7 @@ export function filterConnections(list: ConnectionItem[], query: string): Connec
/** 一路入站当前有多少条连接。 */ /** 一路入站当前有多少条连接。 */
export interface InboundUse { export interface InboundUse {
/** 命名入站显示名字;默认入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */ /** 命名入站显示名字;legacy 入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */
label: string label: string
count: number count: number
upload: number upload: number
+12
View File
@@ -3,9 +3,21 @@ import {
cpuUsageBetween, cpuUsageBetween,
parseGateway, parseGateway,
parseMemAvailable, parseMemAvailable,
parseMemTotal,
parseNameservers, parseNameservers,
} from './host-info.ts' } 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', () => { describe('parseMemAvailable', () => {
test('取 MemAvailable 并换算成字节', () => { test('取 MemAvailable 并换算成字节', () => {
const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n' const meminfo = 'MemTotal: 2048000 kB\nMemFree: 100000 kB\nMemAvailable: 512000 kB\n'
+19 -3
View File
@@ -11,6 +11,7 @@ export interface HostInfo {
/** 0-100。第一次请求没有参照快照,是 null */ /** 0-100。第一次请求没有参照快照,是 null */
cpuUsage: number | null cpuUsage: number | null
cpuCount: number cpuCount: number
/** 内存总量。Linux 用 MemTotal,其余平台退回 totalmem */
memTotal: number memTotal: number
/** 还能用的内存。Linux 用 MemAvailable,其余平台退回 freemem */ /** 还能用的内存。Linux 用 MemAvailable,其余平台退回 freemem */
memAvailable: number memAvailable: number
@@ -37,13 +38,28 @@ export interface ExitProbe {
message?: string message?: string
} }
/** /proc/meminfo 里的 MemAvailable(内核估算的「还能用多少」),换算成字节。 */ function meminfoBytes(meminfo: string, field: string): number | null {
export function parseMemAvailable(meminfo: string): number | null { const match = new RegExp(`^${field}:\\s+(\\d+)\\s*kB`, 'm').exec(meminfo)
const match = /^MemAvailable:\s+(\d+)\s*kB/m.exec(meminfo)
if (!match) return null if (!match) return null
return Number(match[1]) * 1024 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 里的默认网关。 * /proc/net/route 里的默认网关。
* *
+71
View File
@@ -15,6 +15,7 @@ import {
proxyGroupName, proxyGroupName,
pruneGenerated, pruneGenerated,
readSetup, readSetup,
upgradeLegacyInbound,
} from './inbound-groups.ts' } from './inbound-groups.ts'
import type { OutboundSetup } from './inbound-groups.ts' import type { OutboundSetup } from './inbound-groups.ts'
import { collectInbounds } from './inbounds.ts' import { collectInbounds } from './inbounds.ts'
@@ -427,3 +428,73 @@ describe('附加规则', () => {
expect((pruneGenerated(data)['sub-rules'] as ConfigObject).mine).toBeDefined() 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()
})
})
+51 -1
View File
@@ -18,7 +18,7 @@
* 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件) * 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件)
*/ */
import type { ConfigObject } from '../vendor/config-editor/index.ts' 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 type { Inbound } from './inbounds.ts'
import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts' import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts'
import type { RuntimeNode } from './outbound-chain.ts' import type { RuntimeNode } from './outbound-chain.ts'
@@ -433,6 +433,56 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
return next 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 { function referencesTo(data: ConfigObject, name: string): number {
let count = 0 let count = 0
+1 -1
View File
@@ -42,7 +42,7 @@ describe('合并两套写法的入站', () => {
expect(list[1]!.listenerIndex).toBe(0) expect(list[1]!.listenerIndex).toBe(0)
}) })
it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => { it('legacy 入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' }) expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' })
}) })
}) })
+100 -3
View File
@@ -1,17 +1,27 @@
import { describe, expect, it } from 'bun:test' import { describe, expect, it } from 'bun:test'
import { import {
DEFAULT_HEALTH_CHECK,
DEFAULT_INTERVAL, DEFAULT_INTERVAL,
addFileSubscription, addFileSubscription,
addSubscription, addSubscription,
listSubscriptions, listSubscriptions,
providerNames, providerNames,
removeSubscription, removeSubscription,
renameSubscription,
setSubscriptionHealthCheck,
subscriptionPathConflict, subscriptionPathConflict,
subscriptionReferences, subscriptionReferences,
updateSubscription, updateSubscription,
} from './providers.ts' } 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 type { OutboundSetup } from './inbound-groups.ts'
import { collectInbounds } from './inbounds.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts'
const URL = 'http://127.0.0.1:3001/token/download/V2House' const URL = 'http://127.0.0.1:3001/token/download/V2House'
@@ -77,7 +87,7 @@ describe('读出订阅列表', () => {
}) })
describe('新增订阅', () => { describe('新增订阅', () => {
it('自动生成缓存路径和默认间隔', () => { it('自动生成缓存路径、默认间隔和默认开启的健康检查', () => {
const next = addSubscription({}, 'subs2', URL) const next = addSubscription({}, 'subs2', URL)
expect(next['proxy-providers']).toEqual({ expect(next['proxy-providers']).toEqual({
subs2: { subs2: {
@@ -85,6 +95,7 @@ describe('新增订阅', () => {
url: URL, url: URL,
path: './providers/subs2.yaml', path: './providers/subs2.yaml',
interval: DEFAULT_INTERVAL, interval: DEFAULT_INTERVAL,
'health-check': DEFAULT_HEALTH_CHECK,
}, },
}) })
}) })
@@ -106,11 +117,97 @@ describe('新增订阅', () => {
it('从文件导入的订阅是 file 类型,没有地址和间隔', () => { it('从文件导入的订阅是 file 类型,没有地址和间隔', () => {
const next = addFileSubscription({}, 'subs2') const next = addFileSubscription({}, 'subs2')
expect(next['proxy-providers']).toEqual({ 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('编辑订阅', () => { describe('编辑订阅', () => {
it('只改地址和间隔,其余字段原样保留', () => { it('只改地址和间隔,其余字段原样保留', () => {
const next = updateSubscription(base(), 'subs', 'http://example.com/new', 3600) const next = updateSubscription(base(), 'subs', 'http://example.com/new', 3600)
+79 -3
View File
@@ -6,8 +6,8 @@
* 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责, * 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责,
* 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。 * 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。
* *
* 订阅不提供改名:名字被策略组的 `use:` 和生成的副本引用,改名等于改一个被引用的 * 改名必须走 renameSubscription:名字被策略组的 `use:` 和生成的副本引用,改名和
* 标识符,要同步更新所有引用,漏一处就是断引用。删了重建更简单。 * 同步引用要在同一次变换里完成,散开做漏一处就是断引用。
*/ */
import type { ConfigObject } from '../vendor/config-editor/index.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts'
import { isGenerated, providerCachePath } from './inbound-groups.ts' import { isGenerated, providerCachePath } from './inbound-groups.ts'
@@ -15,6 +15,19 @@ import { isGenerated, providerCachePath } from './inbound-groups.ts'
/** 新增订阅时的默认更新间隔,秒。一天一次。 */ /** 新增订阅时的默认更新间隔,秒。一天一次。 */
export const DEFAULT_INTERVAL = 86400 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)。 */ /** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */
export interface Subscription { export interface Subscription {
name: string name: string
@@ -104,6 +117,7 @@ export function addSubscription(
url, url,
path: providerCachePath(name), path: providerCachePath(name),
...(interval !== null ? { interval } : {}), ...(interval !== null ? { interval } : {}),
'health-check': { ...DEFAULT_HEALTH_CHECK },
}, },
} }
return next return next
@@ -137,7 +151,11 @@ export function addFileSubscription(data: ConfigObject, name: string): ConfigObj
const next = { ...data } const next = { ...data }
next['proxy-providers'] = { next['proxy-providers'] = {
...providersOf(data), ...providersOf(data),
[name]: { type: 'file', path: providerCachePath(name) }, [name]: {
type: 'file',
path: providerCachePath(name),
'health-check': { ...DEFAULT_HEALTH_CHECK },
},
} }
return next return next
} }
@@ -162,6 +180,64 @@ export function updateSubscription(
return next 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
}
/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */ /** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */
export function removeSubscription(data: ConfigObject, name: string): ConfigObject { export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
const providers = { ...providersOf(data) } const providers = { ...providersOf(data) }
+1 -1
View File
@@ -45,7 +45,7 @@ function outboundOf(inbound: Inbound): { text: string; chained: boolean } {
<span class="type mono">{{ inboundTypeLabel(inbound.type) }}</span> <span class="type mono">{{ inboundTypeLabel(inbound.type) }}</span>
<span class="name"> <span class="name">
{{ inbound.name }} {{ inbound.name }}
<span v-if="inbound.isDefault" class="tag">默认</span> <span v-if="inbound.isDefault" class="tag">legacy</span>
</span> </span>
<span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']"> <span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']">
<span v-if="outboundOf(inbound).chained" class="chain" title="经过链式代理"> <span v-if="outboundOf(inbound).chained" class="chain" title="经过链式代理">
+2 -17
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间、健康检查开没开。 * 订阅列表。每行一个订阅:名字、当前线路数、上次更新时间。
* *
* 只管显示和选中,不碰任何数据。 * 只管显示和选中,不碰任何数据。
*/ */
@@ -13,8 +13,6 @@ defineProps<{
lines: number | null lines: number | null
/** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */ /** 上次拉取成功的时刻。null 表示不知道或从没拉取过 */
updated: Date | null updated: Date | null
/** 健康检查开没开 */
healthCheck: boolean
}> }>
}>() }>()
const selected = defineModel<string | null>('selected', { required: true }) const selected = defineModel<string | null>('selected', { required: true })
@@ -40,9 +38,6 @@ function formatUpdated(at: Date): string {
<template v-else>—</template> <template v-else>—</template>
</span> </span>
<span class="updated mono">{{ row.updated ? formatUpdated(row.updated) : '—' }}</span> <span class="updated mono">{{ row.updated ? formatUpdated(row.updated) : '—' }}</span>
<span :class="['hc', !row.healthCheck && 'is-off']">
{{ row.healthCheck ? '健康检查' : '—' }}
</span>
</button> </button>
</li> </li>
</ol> </ol>
@@ -66,7 +61,7 @@ function formatUpdated(at: Date): string {
.line { .line {
width: 100%; width: 100%;
display: grid; 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; align-items: center;
gap: var(--s-3); gap: var(--s-3);
padding: var(--s-3) var(--s-4); padding: var(--s-3) var(--s-4);
@@ -98,14 +93,4 @@ function formatUpdated(at: Date): string {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--c-text-soft); color: var(--c-text-soft);
} }
.hc {
text-align: right;
font-size: var(--t-xs);
color: var(--c-text-soft);
}
.hc.is-off {
color: var(--c-text-faint);
}
</style> </style>
+1 -1
View File
@@ -20,7 +20,7 @@ export function modeLabel(mode: unknown): string {
} }
} }
/** 一个监听中的代理端口。name 用配置键原词,和路由页默认入站的命名一致。 */ /** 一个监听中的代理端口。name 用配置键原词,和路由页legacy 入站的命名一致。 */
export interface KernelPort { export interface KernelPort {
name: string name: string
port: number port: number
+28 -4
View File
@@ -15,6 +15,8 @@ import {
listSubscriptions, listSubscriptions,
providerNames, providerNames,
removeSubscription, removeSubscription,
renameSubscription,
setSubscriptionHealthCheck,
subscriptionPathConflict, subscriptionPathConflict,
subscriptionReferences, subscriptionReferences,
updateSubscription, updateSubscription,
@@ -103,7 +105,6 @@ const rows = computed(() =>
name: s.name, name: s.name,
lines: info ? info.proxies.length : null, lines: info ? info.proxies.length : null,
updated: parseUpdated(info?.updatedAt), updated: parseUpdated(info?.updatedAt),
healthCheck: s.healthCheck !== null,
} }
}), }),
) )
@@ -149,7 +150,8 @@ async function submitForm(draft: {
}) { }) {
const editing = formEditing.value const editing = formEditing.value
// 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦(编辑不改名,只查新增) // 名字查重放行了、但净化后的缓存路径和别人相同的,在这里拦
// (只查新增:改名不动缓存路径,不会制造新的撞车)
if (!editing) { if (!editing) {
const path = providerCachePath(draft.name) const path = providerCachePath(draft.name)
const clash = subscriptionPathConflict(props.data, draft.name, path) const clash = subscriptionPathConflict(props.data, draft.name, path)
@@ -173,8 +175,17 @@ async function submitForm(draft: {
} }
} }
// 编辑时先改名再改字段。名字没动时 renameSubscription 原样返回;
// file 类型只有名称可改,不能走 updateSubscription——它会写进 url
const next = editing 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 : draft.file
? addFileSubscription(clone(), draft.name) ? addFileSubscription(clone(), draft.name)
: addSubscription(clone(), draft.name, draft.url, draft.interval) : addSubscription(clone(), draft.name, draft.url, draft.interval)
@@ -182,7 +193,8 @@ async function submitForm(draft: {
// 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了 // 失败时不能关对话框——关了再打开就是空表单,填的名称、地址、间隔全没了
if (!ok) return if (!ok) return
formOpen.value = false formOpen.value = false
if (!editing) selectedName.value = draft.name // 新增和改名都要把选中跟过去,否则选中名失效会被重置到第一行
selectedName.value = draft.name
} }
function askRemove() { function askRemove() {
@@ -205,6 +217,17 @@ async function removeConfirmed() {
await commit(removeSubscription(clone(), target.name), `已删除 ${target.name}`) 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()。 */ /** 让内核立刻重新拉取。运行时动作,不写配置文件,所以不走 commit()。 */
async function refresh() { async function refresh() {
const target = selected.value const target = selected.value
@@ -290,6 +313,7 @@ async function refreshAll() {
@edit="openEdit" @edit="openEdit"
@remove="askRemove" @remove="askRemove"
@refresh="refresh" @refresh="refresh"
@toggle-health-check="toggleHealthCheck"
/> />
</div> </div>
+20 -7
View File
@@ -32,6 +32,7 @@ import OutboundCard from '../components/OutboundCard.vue'
import SourcePicker from '../components/SourcePicker.vue' import SourcePicker from '../components/SourcePicker.vue'
import IconEdit from '~icons/ri/edit-line' import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-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 IconRight from '~icons/ri/arrow-right-s-line'
import IconOffline from '~icons/ri/cloud-off-line' import IconOffline from '~icons/ri/cloud-off-line'
import IconAdd from '~icons/ri/add-line' import IconAdd from '~icons/ri/add-line'
@@ -61,6 +62,8 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
edit: [] edit: []
remove: [] remove: []
/** 把 legacy 入站升级成命名入站。写配置文件,由父组件先弹确认 */
upgrade: []
/** 切换某个策略组当前选中谁 */ /** 切换某个策略组当前选中谁 */
select: [group: string, member: string] select: [group: string, member: string]
test: [name: string, provider: string] test: [name: string, provider: string]
@@ -90,7 +93,7 @@ const lanMissing = computed(() => missingLanRules(props.subRules).length)
*/ */
const subMatchAt = computed(() => firstMatchIndex(props.subRules)) const subMatchAt = computed(() => firstMatchIndex(props.subRules))
/** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */ /** legacy 入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删,只能升级。 */
const locked = computed(() => props.inbound.isDefault) const locked = computed(() => props.inbound.isDefault)
const activeTab = ref('') const activeTab = ref('')
@@ -448,10 +451,15 @@ const complete = computed(
<div class="title"> <div class="title">
<span class="port mono">{{ inbound.port }}</span> <span class="port mono">{{ inbound.port }}</span>
<span class="name">{{ inbound.name }}</span> <span class="name">{{ inbound.name }}</span>
<div v-if="!locked" class="acts"> <div class="acts">
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button> <template v-if="!locked">
<button type="button" class="danger" @click="emit('remove')"> <button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
<IconDelete /> 删除 <button type="button" class="danger" @click="emit('remove')">
<IconDelete /> 删除
</button>
</template>
<button v-else type="button" :disabled="saving" @click="emit('upgrade')">
<IconUpgrade /> 改为命名入站
</button> </button>
</div> </div>
</div> </div>
@@ -842,16 +850,21 @@ const complete = computed(
cursor: pointer; cursor: pointer;
} }
.acts button:hover { .acts button:hover:not(:disabled) {
border-color: var(--c-accent); border-color: var(--c-accent);
color: var(--c-accent-text); color: var(--c-accent-text);
} }
.acts button.danger:hover { .acts button.danger:hover:not(:disabled) {
border-color: var(--c-danger); border-color: var(--c-danger);
color: var(--c-danger); color: var(--c-danger);
} }
.acts button:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.facts { .facts {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
+39
View File
@@ -22,6 +22,7 @@ import {
sharedPeers, sharedPeers,
syncGenerated, syncGenerated,
readSetup, readSetup,
upgradeLegacyInbound,
userProviders, userProviders,
} from '@shared/inbound-groups.ts' } from '@shared/inbound-groups.ts'
import { missingLanRules, moveRule, ruleProvidersOf } from '@shared/rules.ts' import { missingLanRules, moveRule, ruleProvidersOf } from '@shared/rules.ts'
@@ -177,6 +178,30 @@ async function adopt() {
await commit(applySetup(clone(), target.name, defaultSetup()), `${target.name} 已改为逐条选线路`) 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() { function openAdd() {
formEditing.value = null formEditing.value = null
formOpen.value = true formOpen.value = true
@@ -368,6 +393,7 @@ async function removeConfirmed() {
:saving="saving" :saving="saving"
@edit="openEdit" @edit="openEdit"
@remove="askRemove" @remove="askRemove"
@upgrade="askUpgrade"
@select="selectMember" @select="selectMember"
@test="test" @test="test"
@save="saveSetup" @save="saveSetup"
@@ -400,6 +426,19 @@ async function removeConfirmed() {
@confirm="removeConfirmed" @confirm="removeConfirmed"
/> />
<ConfirmDialog
:open="upgrading !== null"
:message="
upgrading
? `将「${upgrading.name}」改为命名入站?端口不变,此后它的流量改查自己的附加规则` +
`(全局规则中 MATCH 之前的部分会复制过来),可以单独选订阅和线路。`
: ''
"
action="升级"
@close="upgrading = null"
@confirm="upgradeConfirmed"
/>
<RuleForm <RuleForm
:open="subFormOpen" :open="subFormOpen"
:editing="subEditing === null ? null : { index: subEditing, raw: subRules[subEditing]! }" :editing="subEditing === null ? null : { index: subEditing, raw: subRules[subEditing]! }"
+17 -15
View File
@@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 选中那个订阅的详情:地址、更新间隔、健康检查,和它带来的线路。 * 选中那个订阅的详情:地址、更新间隔,和它带来的线路。
*
* 健康检查不在字段区展示,只是右上角一个开关动作:默认就是开着的,
* 值得占一行的只有「被谁关掉了」这件事,而按钮文案本身已经说明了状态。
* *
* 线路列表只读。这些线路不在内核的全局名字表里,不能被入站直接引用, * 线路列表只读。这些线路不在内核的全局名字表里,不能被入站直接引用,
* 只能经策略组的 `use:` 整批引入,所以这里没有「换线」可点。 * 只能经策略组的 `use:` 整批引入,所以这里没有「换线」可点。
@@ -13,6 +16,7 @@ import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line' import IconDelete from '~icons/ri/delete-bin-line'
import IconRefresh from '~icons/ri/refresh-line' import IconRefresh from '~icons/ri/refresh-line'
import IconOffline from '~icons/ri/cloud-off-line' import IconOffline from '~icons/ri/cloud-off-line'
import IconPulse from '~icons/ri/pulse-line'
const props = defineProps<{ const props = defineProps<{
subscription: Subscription subscription: Subscription
@@ -30,6 +34,8 @@ const emit = defineEmits<{
remove: [] remove: []
/** 让内核立刻重新拉取。运行时动作,不写配置文件 */ /** 让内核立刻重新拉取。运行时动作,不写配置文件 */
refresh: [] refresh: []
/** 开关健康检查。写配置文件,走保存链路 */
toggleHealthCheck: []
}>() }>()
const lines = computed(() => props.info?.proxies ?? []) const lines = computed(() => props.info?.proxies ?? [])
@@ -48,15 +54,13 @@ function delayClass(ms: number | null): string {
<div class="title"> <div class="title">
<span class="name">{{ subscription.name }}</span> <span class="name">{{ subscription.name }}</span>
<div class="acts"> <div class="acts">
<button type="button" :disabled="saving" @click="emit('toggleHealthCheck')">
<IconPulse /> {{ subscription.healthCheck ? '关闭健康检查' : '开启健康检查' }}
</button>
<button type="button" :disabled="!online || refreshing" @click="emit('refresh')"> <button type="button" :disabled="!online || refreshing" @click="emit('refresh')">
<IconRefresh /> {{ refreshing ? '更新中' : '更新订阅' }} <IconRefresh /> {{ refreshing ? '更新中' : '更新订阅' }}
</button> </button>
<button <button type="button" :disabled="saving" @click="emit('edit')">
type="button"
:disabled="saving || subscription.type === 'file'"
:title="subscription.type === 'file' ? '文件导入的订阅没有可编辑的字段' : undefined"
@click="emit('edit')"
>
<IconEdit /> 编辑 <IconEdit /> 编辑
</button> </button>
<button type="button" class="danger" :disabled="saving" @click="emit('remove')"> <button type="button" class="danger" :disabled="saving" @click="emit('remove')">
@@ -78,14 +82,6 @@ function delayClass(ms: number | null): string {
{{ subscription.interval !== null ? `每 ${subscription.interval} 秒` : '已关闭' }} {{ subscription.interval !== null ? `每 ${subscription.interval} 秒` : '已关闭' }}
</dd> </dd>
</template> </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> </dl>
</header> </header>
@@ -221,6 +217,12 @@ h3 {
padding: 0; padding: 0;
max-height: 420px; max-height: 420px;
overflow-y: auto; overflow-y: auto;
/* 细杆加透明轨道,别让系统滚动条压在延迟列上;gutter 常驻,
线路数跨过滚动阈值时右缘不横跳 */
scrollbar-width: thin;
scrollbar-color: var(--c-border-strong) transparent;
scrollbar-gutter: stable;
padding-right: var(--s-2);
} }
.row { .row {
+11 -7
View File
@@ -3,8 +3,8 @@
* 新增或编辑一个订阅。 * 新增或编辑一个订阅。
* *
* 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成, * 原生 <dialog>,同 InboundForm。缓存路径不写进表单:它由名字自动生成,
* 让用户填只会填出和链式代理对不上的路径。订阅不能改名——名字被策略组的 `use:` * 让用户填只会填出和链式代理对不上的路径。编辑时名字也能改,引用同步和
* 和生成的副本引用,改名要同步所有引用,所以编辑时名字只是静态展示,想换名就删了重建。 * 副本重建由保存链路负责(renameSubscription + syncGenerated)。
* *
* 新增时地址之外还能从文件导入:选了文件就是 file 类型的订阅,没有地址也没有 * 新增时地址之外还能从文件导入:选了文件就是 file 类型的订阅,没有地址也没有
* 自动更新(内核靠文件监视跟进变化),相关字段随之收起。 * 自动更新(内核靠文件监视跟进变化),相关字段随之收起。
@@ -28,7 +28,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
/** 编辑时 name 是被编辑那个订阅的原名。file 非 null 表示从文件导入 */ /** 编辑时 name 和原名不同就是改名。file 非 null 表示从文件导入 */
submit: [ submit: [
draft: { draft: {
name: string name: string
@@ -66,6 +66,8 @@ watch(
const nameError = computed(() => { const nameError = computed(() => {
const n = name.value.trim() const n = name.value.trim()
if (n === '') return '' if (n === '') return ''
// 编辑时名字没动不算重名,takenNames 里有它自己
if (n === props.editing?.name) return ''
// 带这个前缀的会被当成 Patchbay 生成物:列表里不显示,pruneGenerated 还会把它清掉 // 带这个前缀的会被当成 Patchbay 生成物:列表里不显示,pruneGenerated 还会把它清掉
if (n.startsWith(GENERATED_PREFIX)) return `名称不能以 ${GENERATED_PREFIX} 开头` if (n.startsWith(GENERATED_PREFIX)) return `名称不能以 ${GENERATED_PREFIX} 开头`
if (props.takenNames.includes(n)) return '名称已存在' if (props.takenNames.includes(n)) return '名称已存在'
@@ -80,8 +82,9 @@ const intervalInvalid = computed(() => {
const canSubmit = computed(() => { const canSubmit = computed(() => {
if (props.saving) return false 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 (file.value !== null) return true
if (props.editing?.type === 'file') return true
return url.value.trim() !== '' && !intervalInvalid.value return url.value.trim() !== '' && !intervalInvalid.value
}) })
@@ -92,7 +95,7 @@ function onFile(fileName: string, content: string) {
function submit() { function submit() {
if (!canSubmit.value) return if (!canSubmit.value) return
emit('submit', { emit('submit', {
name: props.editing?.name ?? name.value.trim(), name: name.value.trim(),
url: url.value.trim(), url: url.value.trim(),
interval: autoUpdate.value ? Number(interval.value.trim()) : null, interval: autoUpdate.value ? Number(interval.value.trim()) : null,
file: file.value, file: file.value,
@@ -130,7 +133,7 @@ function onBackdrop(event: MouseEvent) {
</header> </header>
<div class="body"> <div class="body">
<label v-if="!editing" class="row"> <label class="row">
<span class="k">名称</span> <span class="k">名称</span>
<span class="v"> <span class="v">
<input v-model="name" type="text" placeholder="subs" autocomplete="off" /> <input v-model="name" type="text" placeholder="subs" autocomplete="off" />
@@ -138,7 +141,8 @@ function onBackdrop(event: MouseEvent) {
</span> </span>
</label> </label>
<template v-if="file === null"> <!-- file 类型没有地址和自动更新,编辑它只有名称可改 -->
<template v-if="file === null && editing?.type !== 'file'">
<label class="row"> <label class="row">
<span class="k">地址</span> <span class="k">地址</span>
<span class="v"> <span class="v">