合并配置页(task/config-page)

# Conflicts:
#	web/api.ts
This commit is contained in:
2026-08-11 20:08:17 +08:00
7 changed files with 1281 additions and 4 deletions
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'bun:test'
import {
DEFAULT_INTERVAL,
addSubscription,
listSubscriptions,
providerNames,
removeSubscription,
subscriptionReferences,
updateSubscription,
} from './providers.ts'
import { applySetup, chainProviderName } from './inbound-groups.ts'
import type { OutboundSetup } from './inbound-groups.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
const URL = 'http://127.0.0.1:3001/token/download/V2House'
/** 一份带一个订阅的配置,形状仿照目标机器上那份。 */
function base(): ConfigObject {
return {
'proxy-providers': {
subs: {
type: 'http',
url: URL,
path: './providers/subs.yaml',
interval: 86400,
'health-check': { enable: true, url: 'https://www.gstatic.com/generate_204', interval: 300 },
},
},
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'], use: ['subs'] }],
}
}
const chainedSetup: OutboundSetup = {
proxy: { sources: ['subs'], filter: null },
chain: { sources: ['subs'], filter: null },
}
describe('读出订阅列表', () => {
it('读出地址、间隔和健康检查', () => {
expect(listSubscriptions(base())).toEqual([
{
name: 'subs',
url: URL,
interval: 86400,
healthCheck: { url: 'https://www.gstatic.com/generate_204', interval: 300 },
},
])
})
it('健康检查没写或没开都算未开启', () => {
const data = base()
const subs = (data['proxy-providers'] as ConfigObject).subs as ConfigObject
delete subs['health-check']
expect(listSubscriptions(data)[0]?.healthCheck).toBeNull()
subs['health-check'] = { enable: false, url: 'https://example.com', interval: 60 }
expect(listSubscriptions(data)[0]?.healthCheck).toBeNull()
})
it('不含 Patchbay 生成的订阅副本', () => {
const data = applySetup(
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
'test1',
chainedSetup,
)
expect(listSubscriptions(data).map((s) => s.name)).toEqual(['subs'])
// 副本还在,只是不列出来
expect(providerNames(data)).toContain(chainProviderName('test1', 'subs'))
})
it('没有 proxy-providers 段时是空列表', () => {
expect(listSubscriptions({ mode: 'rule' })).toEqual([])
})
})
describe('新增订阅', () => {
it('自动生成缓存路径和默认间隔', () => {
const next = addSubscription({}, 'subs2', URL)
expect(next['proxy-providers']).toEqual({
subs2: {
type: 'http',
url: URL,
path: './providers/subs2.yaml',
interval: DEFAULT_INTERVAL,
},
})
})
it('不动已有的订阅', () => {
const data = base()
const next = addSubscription(data, 'subs2', URL)
expect((next['proxy-providers'] as ConfigObject).subs).toEqual(
(data['proxy-providers'] as ConfigObject).subs,
)
})
})
describe('编辑订阅', () => {
it('只改地址和间隔,其余字段原样保留', () => {
const next = updateSubscription(base(), 'subs', 'http://example.com/new', 3600)
const entry = (next['proxy-providers'] as ConfigObject).subs as ConfigObject
expect(entry.url).toBe('http://example.com/new')
expect(entry.interval).toBe(3600)
expect(entry.path).toBe('./providers/subs.yaml')
expect(entry['health-check']).toEqual({
enable: true,
url: 'https://www.gstatic.com/generate_204',
interval: 300,
})
})
it('订阅不存在时不做改动', () => {
const data = base()
expect(updateSubscription(data, 'ghost', URL, 1)).toBe(data)
})
})
describe('删除订阅', () => {
it('从映射里去掉', () => {
const data = addSubscription(base(), 'subs2', URL)
const next = removeSubscription(data, 'subs2')
expect(providerNames(next)).toEqual(['subs'])
})
it('删到最后一个时整个键去掉,不留空映射', () => {
const next = removeSubscription(base(), 'subs')
expect('proxy-providers' in next).toBe(false)
})
it('订阅不存在时不做改动', () => {
const data = base()
expect(removeSubscription(data, 'ghost')).toBe(data)
})
})
describe('删除前的引用检查', () => {
it('找出 use 它的策略组', () => {
expect(subscriptionReferences(base(), 'subs')).toEqual(['PROXY'])
})
it('没人在用时是空列表', () => {
const data = base()
;(data['proxy-groups'] as ConfigObject[])[0] = { name: 'PROXY', type: 'select', proxies: ['DIRECT'] }
expect(subscriptionReferences(data, 'subs')).toEqual([])
})
it('链式代理的专属组 use 的是副本,也算引用原订阅', () => {
const data = applySetup(
{ ...base(), listeners: [{ name: 'test1', type: 'mixed', port: 7778 }] },
'test1',
chainedSetup,
)
const refs = subscriptionReferences(data, 'subs')
// 入口组直接 use 原订阅,三个专属组 use 副本,两边都要认出来
expect(refs).toContain('patchbay:test1 链式')
expect(refs).toContain('patchbay:test1')
})
it('订阅不存在时是空列表', () => {
expect(subscriptionReferences(base(), 'ghost')).toEqual([])
})
})
+146
View File
@@ -0,0 +1,146 @@
/**
* 订阅的读写。
*
* 配置文件里的 `proxy-providers` 段:一个名字对一份声明,内核定时从地址拉回一批线路。
* 这段里除了用户自己加的订阅,还有 Patchbay 为链式代理生成的副本(带 `patchbay:` 前缀)。
* 这个模块只操作用户订阅;副本的增删由 `inbound-groups.ts` 的 applySetup/pruneGenerated 负责,
* 写配置的链路(页面里的 commit)每次都会跑它们,所以这里改完不用补同步。
*
* 订阅不提供改名:名字被策略组的 `use:` 和生成的副本引用,改名等于改一个被引用的
* 标识符,要同步更新所有引用,漏一处就是断引用。删了重建更简单。
*/
import type { ConfigObject } from '../vendor/config-editor/index.ts'
import { isGenerated, providerCachePath } from './inbound-groups.ts'
/** 新增订阅时的默认更新间隔,秒。一天一次。 */
export const DEFAULT_INTERVAL = 86400
/** 一个用户订阅在界面上的样子。只读配置文件的 `proxy-providers` 段(决策记录 0004)。 */
export interface Subscription {
name: string
url: string
/** 更新间隔,秒。null 表示配置里没写 */
interval: number | null
/** 健康检查。没写或 enable 不是 true 都算未开启 */
healthCheck: { url: string | null; interval: number | null } | null
}
function isPlainObject(value: unknown): value is ConfigObject {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function providersOf(data: ConfigObject): ConfigObject {
return isPlainObject(data['proxy-providers']) ? data['proxy-providers'] : {}
}
function groupsOf(data: ConfigObject): ConfigObject[] {
return (Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []).filter(isPlainObject)
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
}
/** 所有用户订阅,按配置里的顺序。不含生成的副本。 */
export function listSubscriptions(data: ConfigObject): Subscription[] {
const result: Subscription[] = []
for (const [name, entry] of Object.entries(providersOf(data))) {
if (isGenerated(name) || !isPlainObject(entry)) continue
const hc = isPlainObject(entry['health-check']) ? entry['health-check'] : null
result.push({
name,
url: typeof entry.url === 'string' ? entry.url : '',
interval: typeof entry.interval === 'number' ? entry.interval : null,
healthCheck:
hc !== null && hc.enable === true
? {
url: typeof hc.url === 'string' ? hc.url : null,
interval: typeof hc.interval === 'number' ? hc.interval : null,
}
: null,
})
}
return result
}
/**
* `proxy-providers` 里的所有名字,含生成的副本。
* 新增订阅挡重名要用全部键:它是映射,和用户看不见的副本撞名也会被静默丢掉。
*/
export function providerNames(data: ConfigObject): string[] {
return Object.keys(providersOf(data))
}
/**
* 新增一个订阅。
*
* `path` 自动给出确定的缓存文件路径,不让用户填:不写的话内核按 url 哈希自己生成一个,
* 那个路径 Patchbay 算不出来,链式代理的副本就没法靠「读同一个缓存文件」认来源
* (见 inbound-groups.ts 的 applySetup)。
*/
export function addSubscription(data: ConfigObject, name: string, url: string): ConfigObject {
const next = { ...data }
next['proxy-providers'] = {
...providersOf(data),
[name]: { type: 'http', url, path: providerCachePath(name), interval: DEFAULT_INTERVAL },
}
return next
}
/** 改订阅的地址和更新间隔。其余字段(缓存路径、健康检查、筛选)原样保留。 */
export function updateSubscription(
data: ConfigObject,
name: string,
url: string,
interval: number,
): ConfigObject {
const entry = providersOf(data)[name]
if (!isPlainObject(entry)) return data
const next = { ...data }
next['proxy-providers'] = {
...providersOf(data),
[name]: { ...entry, url, interval },
}
return next
}
/** 删除一个订阅。删到最后一个时把整个键去掉,不在文件里留空映射。 */
export function removeSubscription(data: ConfigObject, name: string): ConfigObject {
const providers = { ...providersOf(data) }
if (!(name in providers)) return data
delete providers[name]
const next = { ...data }
if (Object.keys(providers).length === 0) delete next['proxy-providers']
else next['proxy-providers'] = providers
return next
}
/**
* 哪些策略组在引用这个订阅。删除前必须先查:被引用的订阅删了,那些组的 `use:` 就空了,
* 而空组会让指向它的入站无路可走。
*
* 引用有两种,第二种容易漏:
* 直接 组的 `use:` 里有这个订阅。链式代理的入口组就是这么引原订阅的
* 间接 开了链式代理的入站,它的专属组 `use:` 的是订阅副本;副本靠和原订阅
* 共用缓存文件(`path` 相同)认来源,名字不可靠解析(见 inbound-groups.ts)
*/
export function subscriptionReferences(data: ConfigObject, name: string): string[] {
const target = providersOf(data)[name]
if (!isPlainObject(target)) return []
const copies = new Set<string>()
if (typeof target.path === 'string') {
for (const [copyName, entry] of Object.entries(providersOf(data))) {
if (isGenerated(copyName) && isPlainObject(entry) && entry.path === target.path) {
copies.add(copyName)
}
}
}
return groupsOf(data)
.filter((group) => {
const use = stringList(group.use)
return use.includes(name) || use.some((u) => copies.has(u))
})
.map((group) => String(group.name))
}