设置页第二轮:行内编辑、GEO 资源展开、恢复与导入分家
单值设置从常驻输入框改成 InlineEdit(纯文本加铅笔,Enter 或失焦提交, 失焦无效直接放弃——失焦是「我走了」,不该把人拽回来)。测速地址加恢复 默认按钮;内核参数逐项提交,不再攒一个保存按钮。 资源一节按文件展开:四类 GEO 文件的大小和更新时间由服务端 stat 内核 工作目录得来(GET /api/geo;文件名和大小写不敏感匹配照 constant/ path.go:18-20,193-224),下载地址可改(geox-url,config.go:363-368, 默认值 578-583)。行内的更新按钮仍是全量更新——内核只有这一个口子, 没变化的文件靠内容哈希跳过。 上传拆成两个入口:恢复只认带 Patchbay 标记的文件,导入收任意配置; 新增恢复策略(兼容只搬接管段落 listeners/proxy-groups/rules/ proxy-providers/rule-providers,覆盖整份替换)。平时是按钮,拖着文件 进窗口时整行变成投放区。
This commit is contained in:
@@ -2,7 +2,7 @@ import { afterAll, describe, expect, it } from 'bun:test'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
||||
import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/**
|
||||
@@ -149,6 +149,33 @@ describe('落盘保护链路(内核在线)', () => {
|
||||
expect(body).toContain('patchbay:')
|
||||
})
|
||||
|
||||
it('覆盖导入整份替换,兼容导入只搬接管段落', async () => {
|
||||
const env = makeEnv()
|
||||
const uploaded = 'mixed-port: 9999\nrules:\n - MATCH,DIRECT\n'
|
||||
|
||||
const compat = await importConfig(env, uploaded, 'compatible')
|
||||
expect(compat.ok).toBe(true)
|
||||
let body = readFileSync(env.configPath, 'utf8')
|
||||
// rules 是接管段落被搬过来,mixed-port 属于机器底座维持当前值
|
||||
expect(body).toContain('MATCH,DIRECT')
|
||||
expect(body).toContain('mixed-port: 7890')
|
||||
expect(body).not.toContain('9999')
|
||||
|
||||
const over = await importConfig(env, uploaded, 'override')
|
||||
expect(over.ok).toBe(true)
|
||||
body = readFileSync(env.configPath, 'utf8')
|
||||
expect(body).toContain('mixed-port: 9999')
|
||||
})
|
||||
|
||||
it('导入解析不过的文件时一个字节都不动', async () => {
|
||||
const env = makeEnv()
|
||||
const result = await importConfig(env, '{{{ 不是 yaml', 'override')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.stage).toBe('self-check')
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('恢复只认列表里的名字,路径穿越进不来', async () => {
|
||||
const env = makeEnv()
|
||||
const result = await restoreBackup(env, '../config.yaml')
|
||||
|
||||
+32
-8
@@ -286,14 +286,29 @@ async function pruneBackups(env: Env): Promise<void> {
|
||||
await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
}
|
||||
|
||||
export type ImportMode = 'override' | 'compatible'
|
||||
|
||||
/**
|
||||
* 用上传的 YAML 原文整份替换当前配置。
|
||||
*
|
||||
* 解析用和读配置同一个引擎,之后走 applyConfig 完整的保护链路(自检、备份、
|
||||
* 重载、失败回退)。没有 baseRevision——整份替换本来就是覆盖语义,同还原备份。
|
||||
* 兼容模式搬运的段落:Patchbay 接管的那几个(决策记录 0003)。
|
||||
* 老式顶层端口、dns、tun 这类属于机器本身的设置不在其中——兼容恢复的语义是
|
||||
* 「把我在 Patchbay 里做的接线搬过来」,不动当前机器的底座。
|
||||
*/
|
||||
export async function importConfig(env: Env, content: string): Promise<ApplyOutcome> {
|
||||
let data: ConfigObject
|
||||
const MANAGED_KEYS = ['listeners', 'proxy-groups', 'rules', 'proxy-providers', 'rule-providers']
|
||||
|
||||
/**
|
||||
* 用上传的 YAML 替换当前配置。
|
||||
*
|
||||
* `override` 整份替换;`compatible` 只把上传文件里 Patchbay 接管的段落搬到
|
||||
* 当前配置上(上传文件里没有的段落维持当前的样子)。解析用和读配置同一个引擎,
|
||||
* 之后走 applyConfig 完整的保护链路(自检、备份、重载、失败回退)。
|
||||
* 没有 baseRevision——替换本来就是覆盖语义,同还原备份。
|
||||
*/
|
||||
export async function importConfig(
|
||||
env: Env,
|
||||
content: string,
|
||||
mode: ImportMode,
|
||||
): Promise<ApplyOutcome> {
|
||||
let uploaded: ConfigObject
|
||||
try {
|
||||
const doc = openDocument(content)
|
||||
const broken = doc.diagnostics.filter((d) => d.severity === 'error')
|
||||
@@ -306,7 +321,7 @@ export async function importConfig(env: Env, content: string): Promise<ApplyOutc
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
data = doc.data
|
||||
uploaded = doc.data
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -316,7 +331,16 @@ export async function importConfig(env: Env, content: string): Promise<ApplyOutc
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
return applyConfig(env, data)
|
||||
|
||||
if (mode === 'override') return applyConfig(env, uploaded)
|
||||
|
||||
const current = await readDocument(env)
|
||||
const merged: ConfigObject = { ...current.data }
|
||||
for (const key of MANAGED_KEYS) {
|
||||
const value = uploaded[key]
|
||||
if (value !== undefined) merged[key] = value
|
||||
}
|
||||
return applyConfig(env, merged)
|
||||
}
|
||||
|
||||
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* GEO 数据库文件的本地状态:多大、多久没更新了。
|
||||
*
|
||||
* 内核不提供这个信息,但文件就躺在它的工作目录里,和 Patchbay 服务端
|
||||
* 在同一台机器上,直接 stat 就是。
|
||||
*/
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { DEFAULT_GEOX_URLS, GEO_FILES } from '../shared/geo.ts'
|
||||
import type { GeoFileStatus } from '../shared/geo.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
export async function geoStatus(
|
||||
env: Env,
|
||||
geoxUrl: Record<string, unknown>,
|
||||
): Promise<GeoFileStatus[]> {
|
||||
const entries = await readdir(env.mihomoHome).catch(() => [] as string[])
|
||||
return Promise.all(
|
||||
GEO_FILES.map(async ({ key, configKey, file }) => {
|
||||
// 内核找 GeoIP.dat/GeoSite.dat 时大小写不敏感(constant/path.go:193-224),这里照做
|
||||
const actual = entries.find((name) => name.toLowerCase() === file.toLowerCase())
|
||||
let size: number | null = null
|
||||
let updatedAt: string | null = null
|
||||
if (actual) {
|
||||
const info = await stat(join(env.mihomoHome, actual)).catch(() => null)
|
||||
if (info) {
|
||||
size = info.size
|
||||
updatedAt = info.mtime.toISOString()
|
||||
}
|
||||
}
|
||||
const configured = geoxUrl[configKey]
|
||||
const url =
|
||||
typeof configured === 'string' && configured !== ''
|
||||
? configured
|
||||
: (DEFAULT_GEOX_URLS[configKey] ?? '')
|
||||
return { key, configKey, url, size, updatedAt }
|
||||
}),
|
||||
)
|
||||
}
|
||||
+15
-3
@@ -13,6 +13,7 @@ import { resolve, sep } from 'node:path'
|
||||
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
|
||||
import { isProviderFilePath } from '../shared/provider-files.ts'
|
||||
import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
||||
import { geoStatus } from './geo.ts'
|
||||
import { hostInfo, probeExit } from './host.ts'
|
||||
import {
|
||||
kernelVersion,
|
||||
@@ -194,13 +195,17 @@ function makeRoute(env: Env, token: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// 上传的 YAML 整份替换当前配置。走 config-store 完整的保护链路
|
||||
// 上传的 YAML 替换当前配置(mode=override 整份,compatible 只搬接管段落)。
|
||||
// 走 config-store 完整的保护链路
|
||||
if (path === '/api/config/import' && req.method === 'POST') {
|
||||
const body = (await req.json()) as { content?: string }
|
||||
const body = (await req.json()) as { content?: string; mode?: string }
|
||||
if (typeof body.content !== 'string' || body.content.trim() === '') {
|
||||
return json({ message: '请求缺少 content 字段' }, 400)
|
||||
}
|
||||
const result = await importConfig(env, body.content)
|
||||
if (body.mode !== 'override' && body.mode !== 'compatible') {
|
||||
return json({ message: 'mode 必须是 override 或 compatible' }, 400)
|
||||
}
|
||||
const result = await importConfig(env, body.content, body.mode)
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
@@ -264,6 +269,13 @@ function makeRoute(env: Env, token: string) {
|
||||
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
|
||||
}
|
||||
|
||||
// GEO 数据库文件的本地状态(大小、更新时间)和生效中的下载地址
|
||||
if (path === '/api/geo' && req.method === 'GET') {
|
||||
const snapshot = await readConfig(env)
|
||||
const geox = (snapshot.data['geox-url'] ?? {}) as Record<string, unknown>
|
||||
return json({ files: await geoStatus(env, geox) })
|
||||
}
|
||||
|
||||
// 更新 GEO 数据库。运行时动作;同步接口,透传层给了长超时
|
||||
if (path === '/api/kernel/geo' && req.method === 'POST') {
|
||||
const upstream = await updateGeoDatabases(env)
|
||||
|
||||
Reference in New Issue
Block a user