订阅健康检查默认开启,编辑支持改名
内核没有默认的健康检查地址,url 为空时检查根本不会运行 (adapter/provider/healthcheck.go:196-199),所以默认值必须写全; 开关只翻 enable,手写的 url/interval 留着下次开启时还用。 改名和策略组 use: 的引用同步收在同一次变换里,链式副本由保存 链路的 syncGenerated 按新名字重建,缓存路径刻意不动——file 类型 的数据本体和副本认亲都靠它。
This commit is contained in:
+100
-3
@@ -1,17 +1,27 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
DEFAULT_HEALTH_CHECK,
|
||||
DEFAULT_INTERVAL,
|
||||
addFileSubscription,
|
||||
addSubscription,
|
||||
listSubscriptions,
|
||||
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 +87,7 @@ describe('读出订阅列表', () => {
|
||||
})
|
||||
|
||||
describe('新增订阅', () => {
|
||||
it('自动生成缓存路径和默认间隔', () => {
|
||||
it('自动生成缓存路径、默认间隔和默认开启的健康检查', () => {
|
||||
const next = addSubscription({}, 'subs2', URL)
|
||||
expect(next['proxy-providers']).toEqual({
|
||||
subs2: {
|
||||
@@ -85,6 +95,7 @@ describe('新增订阅', () => {
|
||||
url: URL,
|
||||
path: './providers/subs2.yaml',
|
||||
interval: DEFAULT_INTERVAL,
|
||||
'health-check': DEFAULT_HEALTH_CHECK,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -106,11 +117,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)
|
||||
|
||||
+79
-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,64 @@ 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
|
||||
}
|
||||
|
||||
/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */
|
||||
export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
|
||||
const providers = { ...providersOf(data) }
|
||||
|
||||
Reference in New Issue
Block a user