diff --git a/server/config-store.test.ts b/server/config-store.test.ts index f19f752..7f0d1ec 100644 --- a/server/config-store.test.ts +++ b/server/config-store.test.ts @@ -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() + }) +}) diff --git a/server/config-store.ts b/server/config-store.ts index d617634..c97ab6a 100644 --- a/server/config-store.ts +++ b/server/config-store.ts @@ -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 { + 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 { 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 { 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 { const dir = await backupDir(env) diff --git a/server/routes.ts b/server/routes.ts index 99346ab..59f65fe 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -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]!) diff --git a/shared/backups.ts b/shared/backups.ts index 3573da1..c31e7f4 100644 --- a/shared/backups.ts +++ b/shared/backups.ts @@ -13,3 +13,17 @@ export function backupTime(name: string): string | null { if (!m) return null return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` } + +/** 备份文件名里的时间戳解析成 Date(服务端本地时间)。名字不合预期时返回 null。 */ +export function backupDate(name: string): Date | null { + const m = STAMP.exec(name) + if (!m) return null + return new Date( + Number(m[1]), + Number(m[2]) - 1, + Number(m[3]), + Number(m[4]), + Number(m[5]), + Number(m[6]), + ) +} diff --git a/shared/diff.test.ts b/shared/diff.test.ts new file mode 100644 index 0000000..1e67f27 --- /dev/null +++ b/shared/diff.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'bun:test' +import { diffHunks, diffLines } from './diff.ts' + +const lines = (rows: ReturnType) => rows.map((r) => `${r.kind}:${r.text}`) + +describe('行级 diff', () => { + it('相同的内容全是 same,折叠后没有片段', () => { + const rows = diffLines('a\nb\nc', 'a\nb\nc') + expect(rows.every((r) => r.kind === 'same')).toBe(true) + expect(diffHunks(rows)).toEqual([]) + }) + + it('中间插入一行', () => { + expect(lines(diffLines('a\nc', 'a\nb\nc'))).toEqual(['same:a', 'add:b', 'same:c']) + }) + + it('中间删除一行', () => { + expect(lines(diffLines('a\nb\nc', 'a\nc'))).toEqual(['same:a', 'del:b', 'same:c']) + }) + + it('改一行等于删旧行加新行', () => { + expect(lines(diffLines('a\nx\nc', 'a\ny\nc'))).toEqual(['same:a', 'del:x', 'add:y', 'same:c']) + }) + + it('从空到有、从有到空', () => { + expect(lines(diffLines('', 'a'))).toEqual(['del:', 'add:a']) + expect(lines(diffLines('a\nb', ''))).toEqual(['del:a', 'del:b', 'add:']) + }) + + it('公共前后缀之外的多处改动都找得到', () => { + const before = ['p1', 'p2', 'x', 'm', 'y', 's1'].join('\n') + const after = ['p1', 'p2', 'x2', 'm', 'y2', 's1'].join('\n') + expect(lines(diffLines(before, after))).toEqual([ + 'same:p1', + 'same:p2', + 'del:x', + 'add:x2', + 'same:m', + 'del:y', + 'add:y2', + 'same:s1', + ]) + }) +}) + +describe('折叠成片段', () => { + it('改动行带前后上下文,远处的未改动行不进片段', () => { + const rows = diffLines( + ['1', '2', '3', '4', '5', '6', '7', '8', '9'].join('\n'), + ['1', '2', '3', 'x', '5', '6', '7', '8', '9'].join('\n'), + ) + const hunks = diffHunks(rows, 1) + expect(hunks).toHaveLength(1) + expect(hunks[0]!.map((r) => r.text)).toEqual(['3', '4', 'x', '5']) + }) + + it('相距很远的两处改动分成两个片段,挨得近就合并成一个', () => { + const before = ['a', '1', '2', '3', '4', '5', '6', '7', 'b'].join('\n') + const afterFar = ['a2', '1', '2', '3', '4', '5', '6', '7', 'b2'].join('\n') + expect(diffHunks(diffLines(before, afterFar), 1)).toHaveLength(2) + const afterNear = ['a2', '1', '2', '3', '4', '5', '6', '7b', 'b'].join('\n') + expect(diffHunks(diffLines(before, afterNear), 4)).toHaveLength(1) + }) +}) diff --git a/shared/diff.ts b/shared/diff.ts new file mode 100644 index 0000000..307c4ae --- /dev/null +++ b/shared/diff.ts @@ -0,0 +1,103 @@ +/** + * 行级 diff,给历史记录的「这次改动改了什么」用。 + * + * 先掐掉公共前后缀再对中段做 LCS:配置文件的一次改动通常只碰几行,中段一般 + * 很小,LCS 的 O(n×m) 表因此撑得住。真遇到两份大面积不同的文件(中段乘积超过 + * 上限),退化成「整段删除 + 整段新增」——依旧正确,只是不够精细。 + */ + +export interface DiffLine { + kind: 'same' | 'add' | 'del' + text: string +} + +const LCS_LIMIT = 1_000_000 + +export function diffLines(before: string, after: string): DiffLine[] { + const a = before.split('\n') + const b = after.split('\n') + + let start = 0 + while (start < a.length && start < b.length && a[start] === b[start]) start += 1 + let endA = a.length + let endB = b.length + while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { + endA -= 1 + endB -= 1 + } + + return [ + ...a.slice(0, start).map((text): DiffLine => ({ kind: 'same', text })), + ...diffMiddle(a.slice(start, endA), b.slice(start, endB)), + ...a.slice(endA).map((text): DiffLine => ({ kind: 'same', text })), + ] +} + +function diffMiddle(a: string[], b: string[]): DiffLine[] { + const allDel = a.map((text): DiffLine => ({ kind: 'del', text })) + const allAdd = b.map((text): DiffLine => ({ kind: 'add', text })) + if (a.length === 0 || b.length === 0 || a.length * b.length > LCS_LIMIT) { + return [...allDel, ...allAdd] + } + + // LCS 长度表倒着填,回溯时从头正着走 + const w = b.length + 1 + const table = new Uint32Array((a.length + 1) * w) + for (let i = a.length - 1; i >= 0; i -= 1) { + for (let j = b.length - 1; j >= 0; j -= 1) { + table[i * w + j] = + a[i] === b[j] + ? table[(i + 1) * w + j + 1]! + 1 + : Math.max(table[(i + 1) * w + j]!, table[i * w + j + 1]!) + } + } + + const rows: DiffLine[] = [] + let i = 0 + let j = 0 + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + rows.push({ kind: 'same', text: a[i]! }) + i += 1 + j += 1 + } else if (table[(i + 1) * w + j]! >= table[i * w + j + 1]!) { + rows.push({ kind: 'del', text: a[i]! }) + i += 1 + } else { + rows.push({ kind: 'add', text: b[j]! }) + j += 1 + } + } + while (i < a.length) rows.push({ kind: 'del', text: a[i++]! }) + while (j < b.length) rows.push({ kind: 'add', text: b[j++]! }) + return rows +} + +/** + * 折叠成只含改动的片段:每个片段是改动行加前后 context 行上下文,片段之间的 + * 未改动区域丢弃(渲染端画省略号)。两份内容相同时返回空数组。 + */ +export function diffHunks(rows: DiffLine[], context = 2): DiffLine[][] { + const keep = new Array(rows.length).fill(false) + rows.forEach((row, index) => { + if (row.kind === 'same') return + const from = Math.max(0, index - context) + const to = Math.min(rows.length - 1, index + context) + for (let k = from; k <= to; k += 1) keep[k] = true + }) + + const hunks: DiffLine[][] = [] + let current: DiffLine[] | null = null + rows.forEach((row, index) => { + if (!keep[index]) { + current = null + return + } + if (!current) { + current = [] + hunks.push(current) + } + current.push(row) + }) + return hunks +} diff --git a/web/api.ts b/web/api.ts index 9acb363..5f62ec4 100644 --- a/web/api.ts +++ b/web/api.ts @@ -173,6 +173,13 @@ export const api = { restoreBackup: (name: string) => request('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`), + /** 一条历史记录的对比原文:这份备份(改动前)和它之后的状态(改动后)。 */ + backupDiff: (name: string) => + request<{ before: string; after: string }>( + 'GET', + `/api/config/backups/${encodeURIComponent(name)}/diff`, + ), + kernel: () => request<{ online: boolean; version?: string }>('GET', '/api/kernel'), /** diff --git a/web/components/HistoryDialog.vue b/web/components/HistoryDialog.vue index db2a6c3..e502617 100644 --- a/web/components/HistoryDialog.vue +++ b/web/components/HistoryDialog.vue @@ -2,12 +2,16 @@ /** * 备份链(历史记录)的弹窗:列出每次改动前留下的备份,点还原直接跳回去。 * 还原走服务端同一条保护链路,当前状态会先自动备份,所以不需要再套一层确认。 + * 点一行展开这次改动的 diff:改动前是备份本身,改动后是更新一份的状态。 */ import { ref, watch } from 'vue' import { backupTime } from '@shared/backups.ts' +import { diffHunks, diffLines } from '@shared/diff.ts' +import type { DiffLine } from '@shared/diff.ts' import { api } from '../api' import { toast, toastError } from '../toast' import IconClose from '~icons/ri/close-line' +import IconRight from '~icons/ri/arrow-right-s-line' const props = defineProps<{ open: boolean }>() @@ -18,12 +22,19 @@ const dialog = ref(null) const backups = ref(null) const busy = ref(false) +/** 展开着的那条。同时只开一条,diff 是逐条看的东西,摊开一串没法读 */ +const expanded = ref(null) +/** 已取回并算好的 diff 片段。null 表示取失败了 */ +const diffs = ref>({}) + watch( () => props.open, (open) => { if (open) { dialog.value?.showModal() backups.value = null + expanded.value = null + diffs.value = {} void api .listBackups() .then((r) => (backups.value = r.backups)) @@ -34,6 +45,19 @@ watch( }, ) +async function toggleDiff(name: string) { + expanded.value = expanded.value === name ? null : name + if (expanded.value !== name || name in diffs.value) return + try { + const { before, after } = await api.backupDiff(name) + diffs.value = { ...diffs.value, [name]: diffHunks(diffLines(before, after)) } + } catch { + diffs.value = { ...diffs.value, [name]: null } + } +} + +const sign = (kind: DiffLine['kind']) => (kind === 'add' ? '+' : kind === 'del' ? '−' : ' ') + async function restore(name: string) { if (busy.value) return busy.value = true @@ -75,9 +99,35 @@ function onBackdrop(event: MouseEvent) { 没有备份。每次保存都会把改动前的状态留在这里,最多 10 份。

    -
  • - {{ backupTime(name) ?? name }} - +
  • +
    +
    + +
    +

    载入中

    +

    对比内容读取失败

    +

    和之后的状态没有差异

    + +
@@ -86,7 +136,7 @@ function onBackdrop(event: MouseEvent) {