反馈改右上角轻提示;侧栏加撤回重做等配置动作
设置页的横幅换成 toast(右滑入、几秒后上移淡出,web/toast.ts 单例): 动作结果看一眼就行,不值得在页面里占一块常驻位置。表单校验类的错误 仍留在各自表单里。 撤回栈就是备份链:撤回把最新备份应用回去并消费掉它,撤回前的状态进 重做栈(.patchbay-redo,同样留 10 份);重做反向;新的正常保存清空 重做栈,不留分叉历史。这两条路复用 applyConfig 的保护链路,用 restoreFrom 参数跳过自建备份——否则备份链会被撤回本身污染,撤两次 等于原地打转。侧栏左下角一排图标:撤回、重做、历史记录(备份列表 弹窗)、更新配置(POST /api/kernel/reload,给 SSH 手改后用)、重启 内核;收拢时容器变窄自动折成一列。 设置页顺手修三处:GEO 行的编辑按钮在输入框开着时再点会闪(blur 关了 click 又开),mousedown.prevent 保焦点、click 显式开关;上传按钮改 「选择或拖放文件」,投放区拖入高亮;恢复策略挪到恢复行下面。
This commit is contained in:
@@ -2,7 +2,16 @@ 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, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
||||
import {
|
||||
applyConfig,
|
||||
importConfig,
|
||||
listBackups,
|
||||
listRedo,
|
||||
readConfig,
|
||||
redoConfig,
|
||||
restoreBackup,
|
||||
undoConfig,
|
||||
} from './config-store.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/**
|
||||
@@ -184,6 +193,48 @@ describe('落盘保护链路(内核在线)', () => {
|
||||
if (!result.ok) expect(result.message).toContain('没有这个备份')
|
||||
})
|
||||
|
||||
it('撤回回到改动前,重做拿回改动,备份链不被撤回污染', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
|
||||
|
||||
const undone = await undoConfig(env)
|
||||
expect(undone.ok).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||||
// 备份被消费、当前状态进了重做栈
|
||||
expect(await listBackups(env)).toHaveLength(0)
|
||||
expect(await listRedo(env)).toHaveLength(1)
|
||||
|
||||
const redone = await redoConfig(env)
|
||||
expect(redone.ok).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7891')
|
||||
expect(await listRedo(env)).toHaveLength(0)
|
||||
expect(await listBackups(env)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('栈空时撤回和重做被拦住,文件不动', async () => {
|
||||
const env = makeEnv()
|
||||
const undone = await undoConfig(env)
|
||||
expect(undone.ok).toBe(false)
|
||||
if (!undone.ok) expect(undone.message).toContain('没有可以撤回')
|
||||
|
||||
const redone = await redoConfig(env)
|
||||
expect(redone.ok).toBe(false)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL)
|
||||
})
|
||||
|
||||
it('新的保存清空重做栈', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision)
|
||||
await undoConfig(env)
|
||||
expect(await listRedo(env)).toHaveLength(1)
|
||||
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7892 }, revision)
|
||||
expect(await listRedo(env)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('备份最多留 10 份,旧的被清掉', async () => {
|
||||
const env = makeEnv()
|
||||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||||
|
||||
+102
-2
@@ -106,11 +106,16 @@ export function rewriteYaml(currentYaml: string, nextData: ConfigObject): string
|
||||
*
|
||||
* `baseRevision` 是客户端读到这份配置时拿到的指纹。传 undefined 表示
|
||||
* 「不管别人改没改,以我为准」,仅用于还原备份这类明确要覆盖的场景。
|
||||
*
|
||||
* `opts.restoreFrom`:撤回/重做专用。给了它就不再新建备份(否则备份链会被
|
||||
* 撤回本身污染,撤两次等于原地打转),重载失败时用它当还原源;同时不清空
|
||||
* 重做栈——只有「正常的新改动」才让已撤回的未来失效。
|
||||
*/
|
||||
export async function applyConfig(
|
||||
env: Env,
|
||||
nextData: ConfigObject,
|
||||
baseRevision?: string,
|
||||
opts?: { restoreFrom?: string },
|
||||
): Promise<ApplyOutcome> {
|
||||
const current = await readDocument(env)
|
||||
|
||||
@@ -185,8 +190,8 @@ export async function applyConfig(
|
||||
}
|
||||
|
||||
// 校验过了才动真文件。备份放在写入之前。
|
||||
const backupPath = await createBackup(env)
|
||||
const backupTiming = lap('备份')
|
||||
const backupPath = opts?.restoreFrom ?? (await createBackup(env))
|
||||
const backupTiming = opts?.restoreFrom ? '' : lap('备份')
|
||||
try {
|
||||
await writeFile(env.configPath, nextYaml, 'utf8')
|
||||
} catch (error) {
|
||||
@@ -204,6 +209,7 @@ export async function applyConfig(
|
||||
if (!kernel.online) {
|
||||
// mihomo -t 已经把过关,文件留给内核下次启动时读。
|
||||
await pruneBackups(env)
|
||||
if (!opts?.restoreFrom) await clearRedo(env)
|
||||
console.log(
|
||||
`[apply] 共 ${String(Date.now() - clock.start)}ms(${checkTiming},${backupTiming},${writeTiming},未重载)`,
|
||||
)
|
||||
@@ -240,6 +246,7 @@ export async function applyConfig(
|
||||
}
|
||||
|
||||
await pruneBackups(env)
|
||||
if (!opts?.restoreFrom) await clearRedo(env)
|
||||
const timings = [checkTiming, backupTiming, writeTiming, reloadTiming].filter(Boolean).join(',')
|
||||
console.log(`[apply] 共 ${String(Date.now() - clock.start)}ms(${timings})`)
|
||||
return {
|
||||
@@ -343,6 +350,99 @@ export async function importConfig(
|
||||
return applyConfig(env, merged)
|
||||
}
|
||||
|
||||
/* ── 撤回 / 重做 ──────────────────────────────────
|
||||
*
|
||||
* 撤回栈就是备份链:每次成功落盘都留了「改动前」的备份。撤回把最新备份应用
|
||||
* 回去并**消费掉它**(从链里删除),同时把撤回前的状态推进重做栈;重做反向。
|
||||
* 任何正常的新改动清空重做栈——和编辑器撤回的通行语义一致,不留分叉历史。
|
||||
*/
|
||||
|
||||
async function redoDir(env: Env): Promise<string> {
|
||||
const dir = join(dirname(env.configPath), '.patchbay-redo')
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
/** 重做条目按名字排序就是按时间排序。带毫秒:连续快速撤回不能互相覆盖。 */
|
||||
function redoName(configPath: string, at: Date): string {
|
||||
const p = (n: number, w = 2) => String(n).padStart(w, '0')
|
||||
const stamp =
|
||||
`${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
|
||||
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}${p(at.getMilliseconds(), 3)}`
|
||||
return `${basename(configPath)}.redo-${stamp}`
|
||||
}
|
||||
|
||||
/** 新的在前,同 listBackups。 */
|
||||
export async function listRedo(env: Env): Promise<string[]> {
|
||||
const dir = await redoDir(env)
|
||||
const prefix = `${basename(env.configPath)}.redo-`
|
||||
const entries = await readdir(dir)
|
||||
return entries.filter((name) => name.startsWith(prefix)).sort().reverse()
|
||||
}
|
||||
|
||||
async function clearRedo(env: Env): Promise<void> {
|
||||
const dir = await redoDir(env)
|
||||
const entries = await listRedo(env)
|
||||
await Promise.all(entries.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
}
|
||||
|
||||
/** 当前文件进重做栈,返回落点路径。 */
|
||||
async function pushRedo(env: Env): Promise<string> {
|
||||
const dir = await redoDir(env)
|
||||
const path = join(dir, redoName(env.configPath, new Date()))
|
||||
await copyFile(env.configPath, path)
|
||||
// 和备份同一个上限。裁掉的是最「未来」的状态,同备份链裁掉最远的过去
|
||||
const stale = (await listRedo(env)).slice(KEEP_BACKUPS)
|
||||
await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
return path
|
||||
}
|
||||
|
||||
/** 撤回最近一次改动:应用最新备份并消费掉它,撤回前的状态进重做栈。 */
|
||||
export async function undoConfig(env: Env): Promise<ApplyOutcome> {
|
||||
const [target] = await listBackups(env)
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: '没有可以撤回的改动',
|
||||
hint: '每次保存都会把改动前的状态留进撤回栈,现在栈是空的。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
const dir = await backupDir(env)
|
||||
const targetYaml = await readFile(join(dir, target), 'utf8')
|
||||
const redoPath = await pushRedo(env)
|
||||
const result = await applyConfig(env, openDocument(targetYaml).data, undefined, {
|
||||
restoreFrom: redoPath,
|
||||
})
|
||||
if (result.ok) await unlink(join(dir, target)).catch(() => {})
|
||||
else await unlink(redoPath).catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
/** 重做最近一次被撤回的改动。当前状态回到撤回栈,重做条目被消费。 */
|
||||
export async function redoConfig(env: Env): Promise<ApplyOutcome> {
|
||||
const [target] = await listRedo(env)
|
||||
if (!target) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: '没有可以重做的改动',
|
||||
hint: '只有撤回过的改动才能重做,新的保存会清空重做栈。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
const dir = await redoDir(env)
|
||||
const targetYaml = await readFile(join(dir, target), 'utf8')
|
||||
const backupPath = await createBackup(env)
|
||||
const result = await applyConfig(env, openDocument(targetYaml).data, undefined, {
|
||||
restoreFrom: backupPath,
|
||||
})
|
||||
if (result.ok) await unlink(join(dir, target)).catch(() => {})
|
||||
else await unlink(backupPath).catch(() => {})
|
||||
return result
|
||||
}
|
||||
|
||||
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
||||
export async function restoreBackup(env: Env, name: string): Promise<ApplyOutcome> {
|
||||
const dir = await backupDir(env)
|
||||
|
||||
+32
-2
@@ -12,13 +12,23 @@
|
||||
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 {
|
||||
applyConfig,
|
||||
importConfig,
|
||||
listBackups,
|
||||
listRedo,
|
||||
readConfig,
|
||||
redoConfig,
|
||||
restoreBackup,
|
||||
undoConfig,
|
||||
} from './config-store.ts'
|
||||
import { geoStatus } from './geo.ts'
|
||||
import { hostInfo, probeExit } from './host.ts'
|
||||
import {
|
||||
kernelVersion,
|
||||
proxyGet,
|
||||
proxyWrite,
|
||||
reloadConfig,
|
||||
restartKernel,
|
||||
selectProxy,
|
||||
updateGeoDatabases,
|
||||
@@ -181,7 +191,19 @@ function makeRoute(env: Env, token: string) {
|
||||
}
|
||||
|
||||
if (path === '/api/config/backups' && req.method === 'GET') {
|
||||
return json({ backups: await listBackups(env) })
|
||||
// redo 是重做栈深度,侧栏用它决定重做按钮亮不亮
|
||||
return json({ backups: await listBackups(env), redo: (await listRedo(env)).length })
|
||||
}
|
||||
|
||||
// 撤回/重做。走 config-store 的保护链路,备份链的进出由它们自己管
|
||||
if (path === '/api/config/undo' && req.method === 'POST') {
|
||||
const result = await undoConfig(env)
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
if (path === '/api/config/redo' && req.method === 'POST') {
|
||||
const result = await redoConfig(env)
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
// 配置文件原文,给「下载当前配置」用。带 attachment 让浏览器按下载处理
|
||||
@@ -269,6 +291,14 @@ function makeRoute(env: Env, token: string) {
|
||||
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
|
||||
}
|
||||
|
||||
// 让内核重新加载磁盘上的配置文件。给 SSH 手改过配置的场景用;运行时动作
|
||||
if (path === '/api/kernel/reload' && req.method === 'POST') {
|
||||
const upstream = await reloadConfig(env, env.configPath)
|
||||
return upstream.ok
|
||||
? json({ ok: true })
|
||||
: json({ message: upstream.message || '内核重载失败' }, 502)
|
||||
}
|
||||
|
||||
// GEO 数据库文件的本地状态(大小、更新时间)和生效中的下载地址
|
||||
if (path === '/api/geo' && req.method === 'GET') {
|
||||
const snapshot = await readConfig(env)
|
||||
|
||||
Reference in New Issue
Block a user