dsh-config-manager 0.1.47 → 0.1.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/README.zh-CN.md +15 -0
- package/lib/client.d.ts +70 -3
- package/lib/client.js +792 -268
- package/lib/client.js.map +1 -1
- package/lib/core/restore.d.ts +7 -0
- package/lib/core/restore.js +4 -0
- package/lib/core/restore.js.map +1 -1
- package/lib/core/run-registry.d.ts +11 -4
- package/lib/core/run-registry.js +14 -7
- package/lib/core/run-registry.js.map +1 -1
- package/lib/index.d.ts +7 -1
- package/lib/index.js +131 -19
- package/lib/index.js.map +1 -1
- package/lib/ui/backup-schedule.d.ts +49 -0
- package/lib/ui/backup-schedule.js +42 -0
- package/lib/ui/backup-schedule.js.map +1 -0
- package/package.json +1 -1
- package/src/client/api.ts +35 -1
- package/src/client/common/ConfirmDialog.tsx +4 -2
- package/src/client/common/ErrorBanner.tsx +8 -4
- package/src/client/common/ReportView.tsx +10 -3
- package/src/client/common/ui.tsx +9 -2
- package/src/client/config-manager.module.css +22 -0
- package/src/client/export/ExportView.tsx +15 -5
- package/src/client/import/ImportWizardView.tsx +75 -14
- package/src/client/locales.ts +46 -0
- package/src/client/market/MarketPanel.tsx +9 -4
- package/src/client/market/MyConfigsView.tsx +8 -8
- package/src/client/run-store.test.ts +112 -1
- package/src/client/run-store.ts +78 -4
- package/src/client/snapshots/SnapshotsPanel.tsx +261 -7
- package/src/client/sync/SyncHistoryView.tsx +16 -2
- package/src/core/restore.ts +7 -0
- package/src/core/run-registry.test.ts +40 -0
- package/src/core/run-registry.ts +16 -9
- package/src/index.ts +129 -18
- package/src/ui/backup-schedule.test.ts +53 -0
- package/src/ui/backup-schedule.ts +83 -0
package/src/client/locales.ts
CHANGED
|
@@ -80,6 +80,7 @@ export const zh = {
|
|
|
80
80
|
'import.importing': '正在导入…(插件安装较多时可能需要几分钟,请勿关闭页面)',
|
|
81
81
|
'import.log.title': '导入日志',
|
|
82
82
|
'import.log.empty': '等待命令输出…',
|
|
83
|
+
'import.log.newOutput': '↓ 新输出',
|
|
83
84
|
'import.skipCurrent': '跳过当前插件',
|
|
84
85
|
'import.skipPending': '正在跳过…其余项将继续导入',
|
|
85
86
|
'import.retrySkipped': '重试失败/跳过的项 ({count})',
|
|
@@ -126,6 +127,7 @@ export const zh = {
|
|
|
126
127
|
'snapshots.noActions': '该快照无可用恢复动作(或全部跳过)。',
|
|
127
128
|
'snapshots.execute': '执行恢复',
|
|
128
129
|
'snapshots.executing': '恢复执行中…',
|
|
130
|
+
'snapshots.confirmTitle': '确认恢复',
|
|
129
131
|
'snapshots.confirmRestore': '确认执行恢复?当前文件会先备份到快照的 pre-restore 目录,插件通过官方 dsh plugin remove 卸载。',
|
|
130
132
|
'snapshots.reportTitle': '恢复报告',
|
|
131
133
|
'snapshots.restored': '已还原',
|
|
@@ -133,6 +135,27 @@ export const zh = {
|
|
|
133
135
|
'snapshots.manualHints': '需人工处理',
|
|
134
136
|
'snapshots.failed': '失败',
|
|
135
137
|
'snapshots.skipped': '跳过',
|
|
138
|
+
// —— 定时全量备份(快照 tab)——
|
|
139
|
+
'backupSchedule.title': '定时全量备份',
|
|
140
|
+
'backupSchedule.hint': '按固定间隔在后台自动导出全量备份 ZIP(恒不含 secret、不加密;要加密请手动导出)。配置存 sync/backup-schedule.json,随 self 分区备份 / 远程同步迁移。',
|
|
141
|
+
'backupSchedule.enabled': '启用定时备份',
|
|
142
|
+
'backupSchedule.enabledHint': '开启后 DSH 在后台按间隔自动备份;每次启动时立即执行一次(距上次运行不足 1 小时则跳过启动触发)。',
|
|
143
|
+
'backupSchedule.interval': '备份间隔',
|
|
144
|
+
'backupSchedule.interval.6h': '每 6 小时',
|
|
145
|
+
'backupSchedule.interval.12h': '每 12 小时',
|
|
146
|
+
'backupSchedule.interval.24h': '每 24 小时',
|
|
147
|
+
'backupSchedule.interval.7d': '每 7 天',
|
|
148
|
+
'backupSchedule.save': '保存设置',
|
|
149
|
+
'backupSchedule.saved': '设置已保存',
|
|
150
|
+
'backupSchedule.runNow': '立即备份',
|
|
151
|
+
'backupSchedule.running': '备份中…',
|
|
152
|
+
'backupSchedule.lastRun': '上次运行',
|
|
153
|
+
'backupSchedule.never': '从未运行',
|
|
154
|
+
'backupSchedule.status.success': '成功',
|
|
155
|
+
'backupSchedule.status.skipped': '已跳过',
|
|
156
|
+
'backupSchedule.status.failed': '失败',
|
|
157
|
+
'backupSchedule.loading': '加载定时备份设置…',
|
|
158
|
+
'backupSchedule.error': '定时备份设置加载失败',
|
|
136
159
|
// —— 报告 ——
|
|
137
160
|
'report.export.title': '导出完成',
|
|
138
161
|
'report.import.title': '导入结果',
|
|
@@ -235,6 +258,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
235
258
|
'import.importing': 'Importing… (installing many plugins may take a few minutes; keep this page open)',
|
|
236
259
|
'import.log.title': 'Import Log',
|
|
237
260
|
'import.log.empty': 'Waiting for command output…',
|
|
261
|
+
'import.log.newOutput': '↓ New output',
|
|
238
262
|
'import.skipCurrent': 'Skip current plugin',
|
|
239
263
|
'import.skipPending': 'Skipping… the rest of the import continues',
|
|
240
264
|
'import.retrySkipped': 'Retry failed/skipped items ({count})',
|
|
@@ -280,6 +304,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
280
304
|
'snapshots.noActions': 'No restorable actions for this snapshot (or all skipped).',
|
|
281
305
|
'snapshots.execute': 'Restore now',
|
|
282
306
|
'snapshots.executing': 'Restoring…',
|
|
307
|
+
'snapshots.confirmTitle': 'Confirm restore',
|
|
283
308
|
'snapshots.confirmRestore': 'Confirm restore? Current files are backed up to the snapshot\'s pre-restore folder first; plugins are removed via the official dsh plugin remove.',
|
|
284
309
|
'snapshots.reportTitle': 'Restore Report',
|
|
285
310
|
'snapshots.restored': 'Restored',
|
|
@@ -287,6 +312,27 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
287
312
|
'snapshots.manualHints': 'Manual action needed',
|
|
288
313
|
'snapshots.failed': 'Failed',
|
|
289
314
|
'snapshots.skipped': 'Skipped',
|
|
315
|
+
// —— Scheduled full backups (snapshots tab) ——
|
|
316
|
+
'backupSchedule.title': 'Scheduled Full Backups',
|
|
317
|
+
'backupSchedule.hint': 'Automatically export a full backup ZIP in the background on a fixed cadence (secrets are never included, never encrypted — use manual export for encryption). Config lives in sync/backup-schedule.json and migrates with the self section.',
|
|
318
|
+
'backupSchedule.enabled': 'Enable scheduled backups',
|
|
319
|
+
'backupSchedule.enabledHint': 'When on, DSH backs up automatically on the cadence below; one run fires on every startup (skipped if less than 1h since the last run).',
|
|
320
|
+
'backupSchedule.interval': 'Backup interval',
|
|
321
|
+
'backupSchedule.interval.6h': 'Every 6 hours',
|
|
322
|
+
'backupSchedule.interval.12h': 'Every 12 hours',
|
|
323
|
+
'backupSchedule.interval.24h': 'Every 24 hours',
|
|
324
|
+
'backupSchedule.interval.7d': 'Every 7 days',
|
|
325
|
+
'backupSchedule.save': 'Save settings',
|
|
326
|
+
'backupSchedule.saved': 'Settings saved',
|
|
327
|
+
'backupSchedule.runNow': 'Back up now',
|
|
328
|
+
'backupSchedule.running': 'Backing up…',
|
|
329
|
+
'backupSchedule.lastRun': 'Last run',
|
|
330
|
+
'backupSchedule.never': 'Never run',
|
|
331
|
+
'backupSchedule.status.success': 'Success',
|
|
332
|
+
'backupSchedule.status.skipped': 'Skipped',
|
|
333
|
+
'backupSchedule.status.failed': 'Failed',
|
|
334
|
+
'backupSchedule.loading': 'Loading scheduled backup settings…',
|
|
335
|
+
'backupSchedule.error': 'Failed to load scheduled backup settings',
|
|
290
336
|
'report.export.title': 'Export Complete',
|
|
291
337
|
'report.import.title': 'Import Result',
|
|
292
338
|
'report.rollback.title': 'Rollback Report',
|
|
@@ -273,14 +273,18 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
|
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
/** 下载 + 校验单条目 → dry-run 详情预览(零写入)。自托管条目(带 repo)必须携带来源仓库。
|
|
276
|
+
/** 下载 + 校验单条目 → dry-run 详情预览(零写入)。自托管条目(带 repo)必须携带来源仓库。
|
|
277
|
+
* 竞态守卫:下载期间用户可发起另一条目下载(downloadingId 已变),晚到响应一律丢弃,
|
|
278
|
+
* 防止「弹窗标题是 B、详情是 A」的串扰。 */
|
|
277
279
|
const runDownload = async (item: MarketListItem): Promise<void> => {
|
|
278
280
|
patch({ downloadingId: item.id, error: null })
|
|
279
281
|
try {
|
|
280
282
|
const detail = await api.download(item.id, item.repo)
|
|
283
|
+
if (stateRef.current.downloadingId !== item.id) return
|
|
281
284
|
// 初始化逐分区批准表:低风险默认勾选,高风险默认不勾选(须逐项显式批准)
|
|
282
285
|
patch({ downloadingId: null, detail, approvals: defaultApprovals(detail.plan) })
|
|
283
286
|
} catch (err) {
|
|
287
|
+
if (stateRef.current.downloadingId !== item.id) return
|
|
284
288
|
patch({ downloadingId: null, error: err instanceof Error ? err.message : String(err) })
|
|
285
289
|
}
|
|
286
290
|
}
|
|
@@ -378,10 +382,10 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
|
|
|
378
382
|
<Card>
|
|
379
383
|
<span className={css.groupLabel}>{t('config.title')}</span>
|
|
380
384
|
<div className={css.actionRow}>
|
|
381
|
-
<Button variant="primary" disabled={state.refreshing || state.importing} onClick={() => { void runRefresh() }}>
|
|
385
|
+
<Button variant="primary" disabled={state.refreshing || state.importing || state.browsing} onClick={() => { void runRefresh() }}>
|
|
382
386
|
{state.refreshing ? <Spinner label={t('config.refreshing')} /> : t('config.refresh')}
|
|
383
387
|
</Button>
|
|
384
|
-
<Button disabled={state.browsing} onClick={() => { void runBrowse() }}>
|
|
388
|
+
<Button disabled={state.browsing || state.refreshing || state.importing} onClick={() => { void runBrowse() }}>
|
|
385
389
|
{state.browsing ? <Spinner label={t('list.loading')} /> : t('list.browse')}
|
|
386
390
|
</Button>
|
|
387
391
|
</div>
|
|
@@ -598,7 +602,8 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
|
|
|
598
602
|
)}
|
|
599
603
|
</div>
|
|
600
604
|
</div>
|
|
601
|
-
|
|
605
|
+
{/* 下载按钮:任一下载进行中全部禁用(防止并发下载详情串扰 + 防重复点击) */}
|
|
606
|
+
<Button disabled={state.downloadingId !== null} onClick={() => { openDownload(it) }}>
|
|
602
607
|
{state.downloadingId === it.id ? <Spinner label={t('common.loading')} /> : t('list.download')}
|
|
603
608
|
</Button>
|
|
604
609
|
</div>
|
|
@@ -556,18 +556,18 @@ export function MyConfigsView({
|
|
|
556
556
|
|
|
557
557
|
/* ------------------------------------------------ 装回本地(复用市场下载 + 逐分区批准链路) */
|
|
558
558
|
|
|
559
|
-
/** 装回本地(复用市场下载 + 逐分区批准链路;条目内容在用户自己的公开仓库,必须带 repo 来源)
|
|
559
|
+
/** 装回本地(复用市场下载 + 逐分区批准链路;条目内容在用户自己的公开仓库,必须带 repo 来源)
|
|
560
|
+
* 竞态守卫:下载期间用户可切换装回另一条目(install.itemId 已变),晚到响应一律丢弃,
|
|
561
|
+
* 防止「会话标题是 B、详情是 A」的串扰。 */
|
|
560
562
|
const runDownload = async (entry: MyItemEntry): Promise<void> => {
|
|
561
563
|
commitInstall({ itemId: entry.id, detail: null, approvals: {}, importing: false, importResult: null, error: null })
|
|
562
564
|
try {
|
|
563
565
|
const detail = await api.download(entry.id, entry.repoUrl)
|
|
564
|
-
if (installRef.current !==
|
|
565
|
-
|
|
566
|
-
}
|
|
566
|
+
if (installRef.current === null || installRef.current.itemId !== entry.id) return
|
|
567
|
+
commitInstall({ ...installRef.current, detail, approvals: defaultApprovals(detail.plan) })
|
|
567
568
|
} catch (err) {
|
|
568
|
-
if (installRef.current !==
|
|
569
|
-
|
|
570
|
-
}
|
|
569
|
+
if (installRef.current === null || installRef.current.itemId !== entry.id) return
|
|
570
|
+
patchInstall({ error: err instanceof Error ? err.message : String(err) })
|
|
571
571
|
}
|
|
572
572
|
}
|
|
573
573
|
|
|
@@ -956,7 +956,7 @@ export function MyConfigsView({
|
|
|
956
956
|
<div className={css.rowActions}>
|
|
957
957
|
<Button onClick={() => { startUpdate(entry); openUpload() }}>{t('myconfigs.item.update')}</Button>
|
|
958
958
|
<Button
|
|
959
|
-
disabled={install !== null && install.
|
|
959
|
+
disabled={install !== null && install.detail === null}
|
|
960
960
|
onClick={() => { openInstall(entry) }}
|
|
961
961
|
>
|
|
962
962
|
{t('myconfigs.item.install')}
|
|
@@ -19,7 +19,8 @@ import assert from 'node:assert/strict'
|
|
|
19
19
|
|
|
20
20
|
import { RunStore, STATE_KEY, type StoreStorage } from './run-store.ts'
|
|
21
21
|
import type { RunProgress } from './common/progress-view.ts'
|
|
22
|
-
import type
|
|
22
|
+
import { MAX_RUN_LOG_LINES, type RunState } from '../core/run-registry.ts'
|
|
23
|
+
import type { RestoreReport } from '../core/restore.ts'
|
|
23
24
|
import type { ImportDecisions } from '../core/types.ts'
|
|
24
25
|
import type { ConfigManagerApi } from './api.ts'
|
|
25
26
|
import {
|
|
@@ -503,6 +504,9 @@ test('m3-poll: watchRunning 经 /runs 发现进行中 run 并轮询 /progress
|
|
|
503
504
|
// 发现后转入 /progress 轮询且回填过真实进度(内部计数 + 当前项名)
|
|
504
505
|
assert.ok(progressCalls >= 2, '发现 runId 后轮询 /progress')
|
|
505
506
|
assert.equal(imp.running, false)
|
|
507
|
+
// 发现活跃 run 时 runId 同步写入 store(fresh run 期间「跳过当前插件」有正确目标,
|
|
508
|
+
// 不会用上一次导入的陈旧 runId —— run-store patchProgress 同步 runId)
|
|
509
|
+
assert.equal(imp.runId, RUN_ID, 'watchRunning 发现 run 后 store.runId 立即填充')
|
|
506
510
|
})
|
|
507
511
|
|
|
508
512
|
test('m3-poll: watchRunning 轮询回填分区/内部计数进度(ProgressBar 徽章数据源)', async () => {
|
|
@@ -565,6 +569,48 @@ test('m3-poll: stopRunWatch 停止发现阶段轮询(视图请求结束后)'
|
|
|
565
569
|
store.stopRunWatch('export')
|
|
566
570
|
})
|
|
567
571
|
|
|
572
|
+
test('m3-poll: 导入日志超过 MAX_RUN_LOG_LINES 后 store progress.log 仍持续更新(P1-3 封顶不冻结)', async () => {
|
|
573
|
+
// 模拟导入日志已满 500 行封顶:后续轮询长度恒为 500,但每次 append 都换新数组引用
|
|
574
|
+
// (run-registry appendLog 不可变写入)。store 的 progress.log 必须持续拿到新引用,
|
|
575
|
+
// 否则 ImportLogPanel 的 memo 按引用比较会判定「无变化」→ 日志面板冻结。
|
|
576
|
+
let log: string[] = []
|
|
577
|
+
for (let i = 0; i < MAX_RUN_LOG_LINES; i++) log = [...log, `line-${i}`]
|
|
578
|
+
const progressResponses: RunState[] = []
|
|
579
|
+
for (let i = 0; i < 4; i++) {
|
|
580
|
+
log = [...log.slice(-(MAX_RUN_LOG_LINES - 1)), `cap-${i}`]
|
|
581
|
+
progressResponses.push(runningRun('import', { section: 'plugins', item: i + 1, itemTotal: 8, detail: 'plugin:pkg', log }))
|
|
582
|
+
}
|
|
583
|
+
progressResponses.push({
|
|
584
|
+
runId: RUN_ID, kind: 'import', status: 'done',
|
|
585
|
+
section: null, sectionTotal: null, item: null, itemTotal: null, detail: null, log: [],
|
|
586
|
+
result: makeImportResult(), createdAt: 1, updatedAt: 3,
|
|
587
|
+
})
|
|
588
|
+
let progressCalls = 0
|
|
589
|
+
const api = makeApi({
|
|
590
|
+
runs: async () => [runningRun('import')],
|
|
591
|
+
progress: async () => progressResponses[Math.min(progressCalls++, progressResponses.length - 1)]!,
|
|
592
|
+
})
|
|
593
|
+
const store = new RunStore({ storage: null, pollIntervalMs: 5 })
|
|
594
|
+
await store.resume(api)
|
|
595
|
+
// 订阅捕获轮询期间的 progress.log 引用
|
|
596
|
+
const seenRefs = new Set<string[]>()
|
|
597
|
+
const seenTails = new Set<string>()
|
|
598
|
+
const unsub = store.subscribe(() => {
|
|
599
|
+
const p = store.getSnapshot().import.progress
|
|
600
|
+
const log = p?.log ?? []
|
|
601
|
+
if (log.length === MAX_RUN_LOG_LINES) {
|
|
602
|
+
seenRefs.add(log)
|
|
603
|
+
seenTails.add(log[log.length - 1]!)
|
|
604
|
+
}
|
|
605
|
+
})
|
|
606
|
+
await sleep(150)
|
|
607
|
+
unsub()
|
|
608
|
+
assert.equal(store.getSnapshot().import.step, 'result', '轮询收敛到完成')
|
|
609
|
+
assert.ok(seenRefs.size >= 3, `封顶后轮询仍拿到新数组引用(实际 ${seenRefs.size} 个不同引用)`)
|
|
610
|
+
assert.ok(seenTails.has('cap-3'), '最新行 cap-3 已到达 store(日志没有冻结在旧行)')
|
|
611
|
+
assert.ok(!seenTails.has('line-499'), '截断保留最新行(cap-* 覆盖 line-* 尾部)')
|
|
612
|
+
})
|
|
613
|
+
|
|
568
614
|
test('m3-poll: 同一 kind 重复 watchRunning 不重复启动', async () => {
|
|
569
615
|
let runsCalls = 0
|
|
570
616
|
const api = makeApi({
|
|
@@ -873,3 +919,68 @@ test('低频面板: 旧版顶层 syncMode 载荷 → 迁移为 git 通道的 byC
|
|
|
873
919
|
assert.equal(st.sync.byChannel.git.selectedSnapshotId, 'snap-old')
|
|
874
920
|
assert.equal(st.sync.byChannel.webdav.syncMode, 'default', 'webdav 通道保持缺省')
|
|
875
921
|
})
|
|
922
|
+
|
|
923
|
+
/* -------------------------------------------------- restore(P1-1)权威状态 */
|
|
924
|
+
|
|
925
|
+
test('低频面板: 快照恢复 running 为瞬态——不写入 sessionStorage、刷新后复位(以宿主 /runs 为权威)', () => {
|
|
926
|
+
const { storage, raw } = makeStorage()
|
|
927
|
+
const first = new RunStore({ storage })
|
|
928
|
+
first.patch({ panel: 'snapshots', snapshots: { selectedId: 'snap-1', running: true } })
|
|
929
|
+
const persisted = JSON.parse(raw() ?? '{}') as { snapshots?: { running?: boolean } }
|
|
930
|
+
assert.equal(persisted.snapshots?.running, false, 'running 恒不落盘(白名单剔除)')
|
|
931
|
+
|
|
932
|
+
// 即便旧载荷携带 running=true,applyPersisted 也硬性归零——绝不把浏览器陈旧状态
|
|
933
|
+
// 当成「恢复仍在执行」的依据(宿主 /runs 是唯一权威,resume() 负责重新置位)
|
|
934
|
+
storage.setItem(STATE_KEY, JSON.stringify({
|
|
935
|
+
...persisted,
|
|
936
|
+
snapshots: { ...persisted.snapshots, running: true },
|
|
937
|
+
}))
|
|
938
|
+
const second = new RunStore({ storage })
|
|
939
|
+
assert.equal(second.getSnapshot().snapshots.running, false, '刷新后 running 复位(不读存储)')
|
|
940
|
+
assert.equal(second.getSnapshot().snapshots.selectedId, 'snap-1', '非瞬态字段仍恢复')
|
|
941
|
+
})
|
|
942
|
+
|
|
943
|
+
test('m2-resume: 活跃 restore run 经 /runs 恢复 running 并轮询到完成回填报告(宿主为权威)', async () => {
|
|
944
|
+
const report: RestoreReport = {
|
|
945
|
+
snapshotId: 'snap-1',
|
|
946
|
+
restored: ['settings.yaml'],
|
|
947
|
+
removedPlugins: ['@scope/pkg'],
|
|
948
|
+
manualHints: [],
|
|
949
|
+
failed: [],
|
|
950
|
+
skipped: [],
|
|
951
|
+
}
|
|
952
|
+
const progressResponses: RunState[] = [
|
|
953
|
+
runningRun('restore', { section: 'restore', item: 1, itemTotal: 3, detail: '整文件还原 settings.yaml' }),
|
|
954
|
+
{
|
|
955
|
+
runId: RUN_ID, kind: 'restore', status: 'done',
|
|
956
|
+
section: null, sectionTotal: null, item: null, itemTotal: null, detail: null, log: [],
|
|
957
|
+
result: report, createdAt: 1, updatedAt: 3,
|
|
958
|
+
},
|
|
959
|
+
]
|
|
960
|
+
let progressCalls = 0
|
|
961
|
+
const api = makeApi({
|
|
962
|
+
runs: async () => [runningRun('restore')],
|
|
963
|
+
progress: async () => progressResponses[Math.min(progressCalls++, progressResponses.length - 1)]!,
|
|
964
|
+
})
|
|
965
|
+
const store = new RunStore({ storage: null, pollIntervalMs: 5 })
|
|
966
|
+
const resumed = await store.resume(api)
|
|
967
|
+
assert.equal(resumed, true, '发现活跃 restore run')
|
|
968
|
+
assert.equal(store.getSnapshot().snapshots.running, true, 'running 镜像置位(宿主 /runs 为权威)')
|
|
969
|
+
|
|
970
|
+
await sleep(80)
|
|
971
|
+
const snap = store.getSnapshot().snapshots
|
|
972
|
+
assert.equal(snap.running, false, '完成后 running 复位')
|
|
973
|
+
assert.deepEqual(snap.report?.restored, ['settings.yaml'], '恢复报告回填 store')
|
|
974
|
+
assert.equal(snap.actionError, null)
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
test('m2-resume: store 镜像显示恢复中但 host 无活跃 restore run → 复位并提示不可恢复', async () => {
|
|
978
|
+
const api = makeApi({ runs: async () => [] })
|
|
979
|
+
const store = new RunStore({ storage: null })
|
|
980
|
+
store.patch({ snapshots: { running: true } })
|
|
981
|
+
const resumed = await store.resume(api)
|
|
982
|
+
assert.equal(resumed, false)
|
|
983
|
+
const snap = store.getSnapshot().snapshots
|
|
984
|
+
assert.equal(snap.running, false, '无宿主 run 时 running 复位(不残留 UI 假状态)')
|
|
985
|
+
assert.ok(snap.actionError !== null, '如实提示结果不可恢复')
|
|
986
|
+
})
|
package/src/client/run-store.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { renderExportReport } from '../ui/report.ts'
|
|
|
38
38
|
import type { FlowPhase } from '../ui/flow.ts'
|
|
39
39
|
import type { ImportStep } from '../ui/types.ts'
|
|
40
40
|
import type { RunProgress } from './common/progress-view.ts'
|
|
41
|
+
import type { BackupScheduleDraft } from '../ui/backup-schedule.ts'
|
|
41
42
|
import type {
|
|
42
43
|
GlobalConflictStrategy, ImportAnalysis, ImportDecisions, ImportPlan, ImportResult,
|
|
43
44
|
ItemResolution, PathMapping,
|
|
@@ -189,13 +190,20 @@ export interface MarketStoreSlice {
|
|
|
189
190
|
myConfirmDeleteId: string | null
|
|
190
191
|
}
|
|
191
192
|
|
|
192
|
-
/** 快照恢复面板的切片(无敏感字段;快照列表本身可随时重载,不持久化)。
|
|
193
|
+
/** 快照恢复面板的切片(无敏感字段;快照列表本身可随时重载,不持久化)。
|
|
194
|
+
* running 为「内存切片瞬态」:切 tab 由模块级单例保留、刷新时被 toPersistedState
|
|
195
|
+
* 白名单剔除 —— 恢复是否仍在执行以宿主 RunRegistry(/runs + /progress)为权威,
|
|
196
|
+
* 刷新后经 resume() 重新发现;浏览器持久化绝不作为 destructive operation 的状态源。 */
|
|
193
197
|
export interface SnapshotsStoreSlice {
|
|
194
198
|
selectedId: string | null
|
|
195
199
|
plan: RestorePlan | null
|
|
200
|
+
/** 真实恢复执行中(瞬态;宿主 /runs 是权威来源,本字段只是镜像) */
|
|
201
|
+
running: boolean
|
|
196
202
|
report: RestoreReport | null
|
|
197
203
|
actionError: string | null
|
|
198
204
|
error: string | null
|
|
205
|
+
/** 定时备份设置草稿(未保存修改切 tab / 刷新保留;null = 无草稿,以宿主配置为准) */
|
|
206
|
+
backupDraft: BackupScheduleDraft | null
|
|
199
207
|
}
|
|
200
208
|
|
|
201
209
|
/* ------------------------------------------------- 持久化(非敏感)状态形状 */
|
|
@@ -422,9 +430,11 @@ function defaultSnapshotsState(): SnapshotsStoreSlice {
|
|
|
422
430
|
return {
|
|
423
431
|
selectedId: null,
|
|
424
432
|
plan: null,
|
|
433
|
+
running: false,
|
|
425
434
|
report: null,
|
|
426
435
|
actionError: null,
|
|
427
436
|
error: null,
|
|
437
|
+
backupDraft: null,
|
|
428
438
|
}
|
|
429
439
|
}
|
|
430
440
|
|
|
@@ -501,9 +511,11 @@ export function toSnapshotsStoreSlice(s: SnapshotsStoreSlice): SnapshotsStoreSli
|
|
|
501
511
|
return {
|
|
502
512
|
selectedId: s.selectedId,
|
|
503
513
|
plan: s.plan,
|
|
514
|
+
running: s.running,
|
|
504
515
|
report: s.report,
|
|
505
516
|
actionError: s.actionError,
|
|
506
517
|
error: s.error,
|
|
518
|
+
backupDraft: s.backupDraft,
|
|
507
519
|
}
|
|
508
520
|
}
|
|
509
521
|
|
|
@@ -555,7 +567,13 @@ export function toPersistedState(state: StoreState): PersistedState {
|
|
|
555
567
|
},
|
|
556
568
|
},
|
|
557
569
|
market: state.market,
|
|
558
|
-
snapshots:
|
|
570
|
+
snapshots: {
|
|
571
|
+
...state.snapshots,
|
|
572
|
+
// 快照恢复 running 为内存切片瞬态:不落盘 —— 恢复是否仍在执行以宿主
|
|
573
|
+
// RunRegistry(/runs + /progress)为权威,刷新后由 resume() 重新发现;
|
|
574
|
+
// 持久化「running=true」会把浏览器陈旧状态误当成宿主真实状态(P1-1 原则)。
|
|
575
|
+
running: false,
|
|
576
|
+
},
|
|
559
577
|
}
|
|
560
578
|
}
|
|
561
579
|
|
|
@@ -896,7 +914,13 @@ export class RunStore {
|
|
|
896
914
|
}
|
|
897
915
|
})(),
|
|
898
916
|
market: { ...defaultMarketState(), ...parsed.market },
|
|
899
|
-
snapshots: {
|
|
917
|
+
snapshots: {
|
|
918
|
+
...defaultSnapshotsState(),
|
|
919
|
+
...parsed.snapshots,
|
|
920
|
+
// 硬性:恢复执行中为瞬态,绝不从存储恢复(即使旧载荷携带 running=true 也清空)——
|
|
921
|
+
// 是否仍有恢复在执行以宿主 /runs 为权威,resume() 会重新发现并置 true
|
|
922
|
+
running: false,
|
|
923
|
+
},
|
|
900
924
|
}
|
|
901
925
|
// 安全兜底:整体加密备份容器已解锁标志绝不从存储恢复(archiveUnlocked 必为 false)→
|
|
902
926
|
// 刷新后只要仍标记为加密容器且已越过 decrypt-archive 阶段,就强制退回重新解锁。
|
|
@@ -1032,6 +1056,25 @@ export class RunStore {
|
|
|
1032
1056
|
},
|
|
1033
1057
|
})
|
|
1034
1058
|
}
|
|
1059
|
+
|
|
1060
|
+
// 快照恢复(P1-1):宿主 /runs 是「恢复是否仍在执行」的权威来源。
|
|
1061
|
+
// 刷新/重开面板后若存在活跃 restore run → 恢复 running 并轮询 /progress
|
|
1062
|
+
// (结果经 applySettled 回填 report);持久化的 running 恒为 false(白名单
|
|
1063
|
+
// 剔除),绝不把浏览器陈旧状态当成宿主真实状态。
|
|
1064
|
+
const restoreRun = active.find((r) => r.kind === 'restore')
|
|
1065
|
+
if (restoreRun !== undefined) {
|
|
1066
|
+
resumed = true
|
|
1067
|
+
this.patch({ snapshots: { running: true, actionError: null } })
|
|
1068
|
+
this.pollRun(restoreRun.runId, 'restore')
|
|
1069
|
+
} else if (this.state.snapshots.running) {
|
|
1070
|
+
// 面板镜像显示恢复中,但 host 无活跃 restore run:请求已结束且响应丢失
|
|
1071
|
+
this.patch({
|
|
1072
|
+
snapshots: {
|
|
1073
|
+
running: false,
|
|
1074
|
+
actionError: '上次恢复任务已结束但结果无法恢复,请重新执行',
|
|
1075
|
+
},
|
|
1076
|
+
})
|
|
1077
|
+
}
|
|
1035
1078
|
return resumed
|
|
1036
1079
|
}
|
|
1037
1080
|
|
|
@@ -1137,7 +1180,13 @@ export class RunStore {
|
|
|
1137
1180
|
private patchProgress(kind: RunKind, state: RunState): void {
|
|
1138
1181
|
const event = mapRunProgress(kind, state)
|
|
1139
1182
|
if (kind === 'export') this.patch({ export: { progress: event } })
|
|
1140
|
-
else this.patch({
|
|
1183
|
+
else if (kind === 'restore') this.patch({ snapshots: { running: true } })
|
|
1184
|
+
else {
|
|
1185
|
+
// 同步写入 runId:watchRunning 发现活跃 import run 时立即填充——
|
|
1186
|
+
// 否则 fresh run(非刷新恢复)期间 store.runId 仍是上一次导入的陈旧值
|
|
1187
|
+
// (/execute 响应在整段导入完成后才带 runId),「跳过当前插件」会打到旧 run。
|
|
1188
|
+
this.patch({ import: { progress: event, runId: state.runId } })
|
|
1189
|
+
}
|
|
1141
1190
|
}
|
|
1142
1191
|
|
|
1143
1192
|
/** run 完成/失败:把 RunState.result 回填 store(并镜像回控制器)。 */
|
|
@@ -1168,6 +1217,22 @@ export class RunStore {
|
|
|
1168
1217
|
}
|
|
1169
1218
|
return
|
|
1170
1219
|
}
|
|
1220
|
+
// restore(P1-1):完成/失败回填快照面板切片(running 复位 + report/actionError)。
|
|
1221
|
+
// 刷新恢复路径与面板本地状态收敛:applySettled 写入 store,面板挂载时经
|
|
1222
|
+
// initFromStore 读取(切 tab 后回来也能看到结果)。
|
|
1223
|
+
if (kind === 'restore') {
|
|
1224
|
+
const result = state.result as RestoreReport | undefined
|
|
1225
|
+
if (state.status === 'done' && result !== undefined && typeof result === 'object') {
|
|
1226
|
+
this.patch({
|
|
1227
|
+
snapshots: { running: false, report: result, actionError: null },
|
|
1228
|
+
})
|
|
1229
|
+
} else {
|
|
1230
|
+
this.patch({
|
|
1231
|
+
snapshots: { running: false, actionError: state.error ?? '恢复失败(结果不可用)' },
|
|
1232
|
+
})
|
|
1233
|
+
}
|
|
1234
|
+
return
|
|
1235
|
+
}
|
|
1171
1236
|
// import
|
|
1172
1237
|
const result = state.result as ImportResult | undefined
|
|
1173
1238
|
if (state.status === 'done' && result !== undefined && typeof result === 'object') {
|
|
@@ -1221,6 +1286,15 @@ export class RunStore {
|
|
|
1221
1286
|
error: '任务已结束或超过保留期,进度不可恢复',
|
|
1222
1287
|
},
|
|
1223
1288
|
})
|
|
1289
|
+
} else if (kind === 'restore') {
|
|
1290
|
+
// 恢复 run 被清理/丢失:复位 running(宿主侧恢复动作可能仍在执行——
|
|
1291
|
+
// 以 RunRegistry 为准,本提示只说明进度不可恢复,不臆断恢复已停止)
|
|
1292
|
+
this.patch({
|
|
1293
|
+
snapshots: {
|
|
1294
|
+
running: false,
|
|
1295
|
+
actionError: '恢复任务已结束或超过保留期,进度不可恢复',
|
|
1296
|
+
},
|
|
1297
|
+
})
|
|
1224
1298
|
} else {
|
|
1225
1299
|
this.importWizardInst?.reset()
|
|
1226
1300
|
this.patch({
|