历史记录:每条可展开 diff,连续保存合并进 5 秒窗
备份存的是「改动前」,所以一条记录的 diff 两端是它自己和更新一份的 状态(最新一条对比当前文件)。行级 diff 先掐公共前后缀再做 LCS,一次 改动通常只碰几行,表撑得住;大面积不同时退化成整段删增。 合并窗做在服务端而不是前端防抖:普通保存(opts.merge)发现最新备份 还在窗内就不新建,连点删除共享一条历史、一步撤回。窗锚定在这一串的 第一下,不滚雪球;还原、导入、撤回重做不合并,各占一条。合并时没有 备份文件,重载失败的还原改用刚读出的原文写回。顺带消除了备份名只精 确到秒、同秒保存互相覆盖的隐患。
This commit is contained in:
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
applyConfig,
|
||||
backupDiff,
|
||||
importConfig,
|
||||
listBackups,
|
||||
listRedo,
|
||||
@@ -253,3 +254,83 @@ describe('落盘保护链路(内核在线)', () => {
|
||||
expect(backups).not.toContain('config.yaml.bak-20200101-000010')
|
||||
})
|
||||
})
|
||||
|
||||
describe('连续保存的合并窗(opts.merge)', () => {
|
||||
const stampName = (at: Date) => {
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return (
|
||||
`config.yaml.bak-${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
|
||||
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`
|
||||
)
|
||||
}
|
||||
|
||||
it('窗内的第二次保存不新建备份,一步撤回回到这一串之前', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
|
||||
const second = await readConfig(env)
|
||||
await applyConfig(env, { ...second.data, 'mixed-port': 7892 }, second.revision, { merge: true })
|
||||
|
||||
expect(await listBackups(env)).toHaveLength(1)
|
||||
|
||||
const undone = await undoConfig(env)
|
||||
expect(undone.ok).toBe(true)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||||
})
|
||||
|
||||
it('最新备份出了窗就正常新建', async () => {
|
||||
const env = makeEnv()
|
||||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, stampName(new Date(Date.now() - 60_000))), 'old', 'utf8')
|
||||
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision, { merge: true })
|
||||
|
||||
expect(await listBackups(env)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('不带 merge 的保存(还原、导入)不合并,窗内照样各留一条', async () => {
|
||||
const env = makeEnv()
|
||||
const first = await readConfig(env)
|
||||
await applyConfig(env, { ...first.data, 'mixed-port': 7891 }, first.revision, { merge: true })
|
||||
const [backup] = await listBackups(env)
|
||||
|
||||
const result = await restoreBackup(env, backup!)
|
||||
expect(result.ok).toBe(true)
|
||||
// 还原自己也备份了「还原前」的状态。名字按秒生成,和上一份同秒时会覆盖而不是
|
||||
// 新增,所以这里断言的是「至少还有一份、且文件回到了原样」
|
||||
expect((await listBackups(env)).length).toBeGreaterThanOrEqual(1)
|
||||
expect(readFileSync(env.configPath, 'utf8')).toContain('mixed-port: 7890')
|
||||
})
|
||||
})
|
||||
|
||||
describe('历史记录的对比原文', () => {
|
||||
it('最新一条对比当前文件,改动前后各归各', async () => {
|
||||
const env = makeEnv()
|
||||
const { data, revision } = await readConfig(env)
|
||||
await applyConfig(env, { ...data, 'mixed-port': 7891 }, revision)
|
||||
|
||||
const [newest] = await listBackups(env)
|
||||
const pair = await backupDiff(env, newest!)
|
||||
expect(pair).not.toBeNull()
|
||||
expect(pair!.before).toBe(ORIGINAL)
|
||||
expect(pair!.after).toContain('mixed-port: 7891')
|
||||
})
|
||||
|
||||
it('更老的一条对比它前面那份备份', async () => {
|
||||
const env = makeEnv()
|
||||
const dir = join(env.configPath, '..', '.patchbay-backups')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'config.yaml.bak-20200101-000001'), '第一份\n', 'utf8')
|
||||
writeFileSync(join(dir, 'config.yaml.bak-20200101-000002'), '第二份\n', 'utf8')
|
||||
|
||||
const pair = await backupDiff(env, 'config.yaml.bak-20200101-000001')
|
||||
expect(pair).toEqual({ before: '第一份\n', after: '第二份\n' })
|
||||
})
|
||||
|
||||
it('名字不在备份列表里返回 null', async () => {
|
||||
const env = makeEnv()
|
||||
expect(await backupDiff(env, '../../etc/passwd')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
+55
-6
@@ -30,6 +30,7 @@ import {
|
||||
} from '../vendor/config-editor/index.ts'
|
||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts'
|
||||
import { backupDate } from '../shared/backups.ts'
|
||||
import { withMarker } from '../shared/config-marker.ts'
|
||||
import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts'
|
||||
import type { Env } from './env.ts'
|
||||
@@ -37,6 +38,22 @@ import type { Env } from './env.ts'
|
||||
/** 备份保留的份数。再多没有意义——真要回到很久以前的版本,那是版本控制的活。 */
|
||||
const KEEP_BACKUPS = 10
|
||||
|
||||
/**
|
||||
* 界面上的连续保存合并成一条历史的时间窗:最新备份还在窗内就不再新建,
|
||||
* 这一串操作共享一条记录、一步撤回。连点删除不再刷出一屏历史。
|
||||
* 顺带解决了备份名只精确到秒的隐患——窗内不建新备份,同秒的覆盖不会发生。
|
||||
*/
|
||||
const MERGE_WINDOW_MS = 5_000
|
||||
|
||||
/** 最新备份是否还在合并窗内。时间戳从备份名里解析,时钟往回跳时当不在窗内。 */
|
||||
async function hasRecentBackup(env: Env): Promise<boolean> {
|
||||
const [newest] = await listBackups(env)
|
||||
const at = newest ? backupDate(newest) : null
|
||||
if (!at) return false
|
||||
const age = Date.now() - at.getTime()
|
||||
return age >= 0 && age < MERGE_WINDOW_MS
|
||||
}
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
/** 解析出来的配置数据 */
|
||||
data: ConfigObject
|
||||
@@ -110,12 +127,15 @@ export function rewriteYaml(currentYaml: string, nextData: ConfigObject): string
|
||||
* `opts.restoreFrom`:撤回/重做专用。给了它就不再新建备份(否则备份链会被
|
||||
* 撤回本身污染,撤两次等于原地打转),重载失败时用它当还原源;同时不清空
|
||||
* 重做栈——只有「正常的新改动」才让已撤回的未来失效。
|
||||
*
|
||||
* `opts.merge`:界面上的普通保存专用。最新备份还在合并窗内时不新建备份,
|
||||
* 连续操作共享一条历史。还原备份、上传导入这类独立动作不传,各占一条。
|
||||
*/
|
||||
export async function applyConfig(
|
||||
env: Env,
|
||||
nextData: ConfigObject,
|
||||
baseRevision?: string,
|
||||
opts?: { restoreFrom?: string },
|
||||
opts?: { restoreFrom?: string; merge?: boolean },
|
||||
): Promise<ApplyOutcome> {
|
||||
const current = await readDocument(env)
|
||||
|
||||
@@ -189,9 +209,11 @@ export async function applyConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// 校验过了才动真文件。备份放在写入之前。
|
||||
const backupPath = opts?.restoreFrom ?? (await createBackup(env))
|
||||
const backupTiming = opts?.restoreFrom ? '' : lap('备份')
|
||||
// 校验过了才动真文件。备份放在写入之前;合并窗内跳过新建,重载失败的还原
|
||||
// 不受影响——没有备份文件时用刚读出的原文写回(见下面的还原分支)。
|
||||
const merged = opts?.merge === true && opts.restoreFrom === undefined && (await hasRecentBackup(env))
|
||||
const backupPath = opts?.restoreFrom ?? (merged ? null : await createBackup(env))
|
||||
const backupTiming = backupPath !== null && opts?.restoreFrom === undefined ? lap('备份') : ''
|
||||
try {
|
||||
await writeFile(env.configPath, nextYaml, 'utf8')
|
||||
} catch (error) {
|
||||
@@ -226,9 +248,11 @@ export async function applyConfig(
|
||||
if (!reloaded.ok) {
|
||||
// 文件已经写进去了但内核没接受。把文件还原,再让内核载回原样,
|
||||
// 避免留下「文件是新的、内核跑的是旧的」这种最难排查的状态。
|
||||
// 合并窗内没有备份文件,还原源用刚读出的原文。
|
||||
let restored = false
|
||||
try {
|
||||
await copyFile(backupPath, env.configPath)
|
||||
if (backupPath !== null) await copyFile(backupPath, env.configPath)
|
||||
else await writeFile(env.configPath, current.yaml, 'utf8')
|
||||
await reloadConfig(env, env.configPath)
|
||||
restored = true
|
||||
} catch {
|
||||
@@ -240,7 +264,9 @@ export async function applyConfig(
|
||||
message: reloaded.message,
|
||||
hint: restored
|
||||
? '已经把配置还原成改动前的样子,内核也载回去了。改动没有生效。'
|
||||
: `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。`,
|
||||
: backupPath !== null
|
||||
? `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。`
|
||||
: '还原也失败了。历史记录里最新的备份和改动前至多差几秒内的操作,可以从它还原。',
|
||||
restored,
|
||||
}
|
||||
}
|
||||
@@ -443,6 +469,29 @@ export async function redoConfig(env: Env): Promise<ApplyOutcome> {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条历史记录展开时要对比的两份原文。
|
||||
*
|
||||
* 备份存的是「改动前」,所以这次改动的对比对象是更新一份的状态:列表里
|
||||
* 它前面那份备份,最新一条则对比当前文件。名字对着 listBackups 校验,
|
||||
* 同 restoreBackup,防路径穿越。没有这个备份时返回 null。
|
||||
*/
|
||||
export async function backupDiff(
|
||||
env: Env,
|
||||
name: string,
|
||||
): Promise<{ before: string; after: string } | null> {
|
||||
const backups = await listBackups(env)
|
||||
const index = backups.indexOf(name)
|
||||
if (index < 0) return null
|
||||
const dir = await backupDir(env)
|
||||
const before = await readFile(join(dir, name), 'utf8')
|
||||
const after =
|
||||
index === 0
|
||||
? await readFile(env.configPath, 'utf8')
|
||||
: await readFile(join(dir, backups[index - 1]!), 'utf8')
|
||||
return { before, after }
|
||||
}
|
||||
|
||||
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
||||
export async function restoreBackup(env: Env, name: string): Promise<ApplyOutcome> {
|
||||
const dir = await backupDir(env)
|
||||
|
||||
+11
-1
@@ -14,6 +14,7 @@ import listenerFields from '../shared/listener-fields.json' with { type: 'json'
|
||||
import { isProviderFilePath } from '../shared/provider-files.ts'
|
||||
import {
|
||||
applyConfig,
|
||||
backupDiff,
|
||||
importConfig,
|
||||
listBackups,
|
||||
listRedo,
|
||||
@@ -186,7 +187,8 @@ function makeRoute(env: Env, token: string) {
|
||||
if (!body.data || typeof body.data !== 'object') {
|
||||
return json({ message: '请求缺少 data 字段' }, 400)
|
||||
}
|
||||
const result = await applyConfig(env, body.data, body.baseRevision)
|
||||
// merge:界面上的连续保存在时间窗内合并成一条历史。还原、导入不走这里
|
||||
const result = await applyConfig(env, body.data, body.baseRevision, { merge: true })
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
@@ -231,6 +233,14 @@ function makeRoute(env: Env, token: string) {
|
||||
return result.ok ? json(result) : json(result, applyFailureStatus(result.stage))
|
||||
}
|
||||
|
||||
// 一条历史记录的对比原文:这份备份(改动前)和它之后的状态(改动后)
|
||||
const diffMatch = /^\/api\/config\/backups\/([^/]+)\/diff$/.exec(path)
|
||||
if (diffMatch && req.method === 'GET') {
|
||||
const pair = await backupDiff(env, decodeURIComponent(diffMatch[1]!))
|
||||
if (!pair) return json({ message: '没有这个备份', hint: '刷新一下历史记录再试。' }, 404)
|
||||
return json(pair)
|
||||
}
|
||||
|
||||
const restoreMatch = /^\/api\/config\/backups\/([^/]+)\/restore$/.exec(path)
|
||||
if (restoreMatch && req.method === 'POST') {
|
||||
const name = decodeURIComponent(restoreMatch[1]!)
|
||||
|
||||
Reference in New Issue
Block a user