dsh-config-manager 0.1.48 → 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/lib/client.d.ts +5 -3
- package/lib/client.js +206 -126
- 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 +54 -8
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +3 -1
- package/src/client/config-manager.module.css +22 -0
- package/src/client/export/ExportView.tsx +2 -2
- package/src/client/import/ImportWizardView.tsx +22 -16
- package/src/client/run-store.test.ts +112 -1
- package/src/client/run-store.ts +73 -4
- package/src/client/snapshots/SnapshotsPanel.tsx +20 -2
- package/src/client/sync/SyncHistoryView.tsx +1 -0
- 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 +53 -8
package/src/client/api.ts
CHANGED
|
@@ -82,11 +82,13 @@ export interface DecryptArchiveResponse {
|
|
|
82
82
|
refs: string[];
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/** restore 端点响应:dryRun=true 返回 plan;执行返回 report(与 CLI
|
|
85
|
+
/** restore 端点响应:dryRun=true 返回 plan;执行返回 report(与 CLI 一致的诚实报告)。
|
|
86
|
+
* runId 仅真实执行时存在(宿主 RunRegistry 登记;/runs + /progress 刷新恢复用)。 */
|
|
86
87
|
export interface RestoreResponse {
|
|
87
88
|
dryRun: boolean;
|
|
88
89
|
plan?: RestorePlan;
|
|
89
90
|
report?: RestoreReport;
|
|
91
|
+
runId?: string;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
/** Star 引导弹窗状态(GET /star-prompt;纯偏好,无 secret) */
|
|
@@ -575,6 +575,28 @@
|
|
|
575
575
|
font-size: 12.5px;
|
|
576
576
|
font-weight: 600;
|
|
577
577
|
color: var(--dsw-alias-label-secondary);
|
|
578
|
+
display: flex;
|
|
579
|
+
align-items: center;
|
|
580
|
+
gap: 8px;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* 「↓ 新输出」跳底按钮:用户上滚后有新行时出现(§24 自动滚动纪律);
|
|
584
|
+
复用 ghost 语义(L2 描边 + 细圆角),hover 走交互底色,不新增视觉体系 */
|
|
585
|
+
.logJumpButton {
|
|
586
|
+
font-family: var(--dsw-font-family);
|
|
587
|
+
font-size: 11px;
|
|
588
|
+
line-height: 1;
|
|
589
|
+
color: var(--dsw-alias-label-secondary);
|
|
590
|
+
background: transparent;
|
|
591
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
592
|
+
border-radius: 999px;
|
|
593
|
+
padding: 3px 8px;
|
|
594
|
+
cursor: pointer;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
.logJumpButton:hover {
|
|
598
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
599
|
+
color: var(--dsw-alias-label-primary);
|
|
578
600
|
}
|
|
579
601
|
|
|
580
602
|
/* 命令日志容器:限高内滚(长日志不把整页撑长;见 DESIGN.md §7.2 logScroll) */
|
|
@@ -252,12 +252,12 @@ export function ExportView({ api, t }: ExportViewProps) {
|
|
|
252
252
|
{running && <ProgressBar event={progress} active />}
|
|
253
253
|
|
|
254
254
|
{error !== null && (
|
|
255
|
-
<ErrorBanner error={error} onRetry={() => { void runExport() }} retrying={running} />
|
|
255
|
+
<ErrorBanner error={error} onRetry={() => { void runExport() }} retrying={running} t={api.t} />
|
|
256
256
|
)}
|
|
257
257
|
|
|
258
258
|
{result !== null && !running && (
|
|
259
259
|
<>
|
|
260
|
-
<ReportView kind="export" exportReport={result.report} onDownload={() => { void download(result.zipPath) }} downloadBusy={downloading} />
|
|
260
|
+
<ReportView kind="export" exportReport={result.report} onDownload={() => { void download(result.zipPath) }} downloadBusy={downloading} t={api.t} />
|
|
261
261
|
{downloaded && <Banner kind="ok">{t('export.saved', { name: result.report.file.name })}</Banner>}
|
|
262
262
|
</>
|
|
263
263
|
)}
|
|
@@ -114,21 +114,26 @@ function ImportLogPanelBase({ lines, t }: { lines: string[]; t: TranslateNS<'con
|
|
|
114
114
|
const stickRef = useRef(true)
|
|
115
115
|
/** 用户上滚后是否有新行到达(显示「↓ 新输出」;点击跳到底部清除) */
|
|
116
116
|
const [hasNewOutput, setHasNewOutput] = useState(false)
|
|
117
|
-
/**
|
|
118
|
-
|
|
117
|
+
/**
|
|
118
|
+
* 上次渲染的数组引用(新输出 = 引用变化)。依赖 appendLog 的**不可变写入**:
|
|
119
|
+
* 每次追加都生成新数组(run-registry.ts)——行数封顶后长度恒定,但引用必变,
|
|
120
|
+
* 以引用判断才能感知截断后的新行(长度比较在 500 行封顶时失效)。
|
|
121
|
+
*/
|
|
122
|
+
const prevLinesRef = useRef(lines)
|
|
119
123
|
|
|
120
124
|
useEffect(() => {
|
|
121
125
|
const el = scrollRef.current
|
|
122
126
|
if (el === null) return
|
|
127
|
+
const hasNew = lines !== prevLinesRef.current
|
|
128
|
+
prevLinesRef.current = lines
|
|
123
129
|
if (stickRef.current) {
|
|
124
130
|
el.scrollTop = el.scrollHeight
|
|
125
131
|
setHasNewOutput(false)
|
|
126
|
-
} else if (
|
|
132
|
+
} else if (hasNew) {
|
|
127
133
|
// 用户已上滚且有新行到达:提示而非强制拉回(§24 自动滚动纪律)
|
|
128
134
|
setHasNewOutput(true)
|
|
129
135
|
}
|
|
130
|
-
|
|
131
|
-
}, [lines.length])
|
|
136
|
+
}, [lines])
|
|
132
137
|
|
|
133
138
|
/** 滚动中更新贴底状态(上滚 → 停止跟随;滚回底部 → 恢复跟随并清除提示) */
|
|
134
139
|
const onScroll = (): void => {
|
|
@@ -168,10 +173,11 @@ function ImportLogPanelBase({ lines, t }: { lines: string[]; t: TranslateNS<'con
|
|
|
168
173
|
)
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
/** memo:lines
|
|
172
|
-
*
|
|
176
|
+
/** memo:lines 数组经 appendLog **不可变追加**(每次 append 换新引用,run-registry.ts)——
|
|
177
|
+
* 自定义比较以「数组引用 + t 引用」为准:引用未变 = 无新输出,跳过整个列表重渲染;
|
|
178
|
+
* 引用已变 = 有新行(含 500 行封顶后长度不变的情况),必须重渲染。 */
|
|
173
179
|
const ImportLogPanel = memo(ImportLogPanelBase, (prev, next) =>
|
|
174
|
-
prev.lines
|
|
180
|
+
prev.lines === next.lines && prev.t === next.t,
|
|
175
181
|
)
|
|
176
182
|
|
|
177
183
|
/**
|
|
@@ -505,7 +511,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
|
|
|
505
511
|
{uploading ? <Spinner label={t('import.analyzing')} /> : t(browseLabelKey(selectModel.selectedName !== null))}
|
|
506
512
|
</Button>
|
|
507
513
|
</div>
|
|
508
|
-
{error !== null && <ErrorBanner error={error} onRetry={resetWizard} />}
|
|
514
|
+
{error !== null && <ErrorBanner error={error} onRetry={resetWizard} t={api.t} />}
|
|
509
515
|
</div>
|
|
510
516
|
)
|
|
511
517
|
}
|
|
@@ -514,7 +520,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
|
|
|
514
520
|
return (
|
|
515
521
|
<div className={css.viewBody}>
|
|
516
522
|
<ProgressBar event={progress} active />
|
|
517
|
-
{error !== null && <ErrorBanner error={error} onRetry={resetWizard} />}
|
|
523
|
+
{error !== null && <ErrorBanner error={error} onRetry={resetWizard} t={api.t} />}
|
|
518
524
|
<ErrorList errors={imp.errors} />
|
|
519
525
|
</div>
|
|
520
526
|
)
|
|
@@ -541,7 +547,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
|
|
|
541
547
|
{analysis.warnings.map((w, i) => <div key={i}>{w}</div>)}
|
|
542
548
|
</Banner>
|
|
543
549
|
)}
|
|
544
|
-
{error !== null && <ErrorBanner error={error} onRetry={() => { void goPreview() }} />}
|
|
550
|
+
{error !== null && <ErrorBanner error={error} onRetry={() => { void goPreview() }} t={api.t} />}
|
|
545
551
|
<div className={css.actionRow}>
|
|
546
552
|
<Button variant="ghost" onClick={resetWizard}>{t('import.select.reselect')}</Button>
|
|
547
553
|
<Button variant="primary" onClick={() => { void goPreview() }}>{t('common.next')}</Button>
|
|
@@ -568,7 +574,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
|
|
|
568
574
|
</div>
|
|
569
575
|
{isEncrypted && <Banner kind="warn">{t('import.decrypt.previewHint')}</Banner>}
|
|
570
576
|
{summary.needsRestart && <Banner kind="warn">{t('import.preview.restart')}</Banner>}
|
|
571
|
-
{error !== null && <ErrorBanner error={error} onRetry={() => { void goPreview() }} />}
|
|
577
|
+
{error !== null && <ErrorBanner error={error} onRetry={() => { void goPreview() }} t={api.t} />}
|
|
572
578
|
<div className={css.actionRow}>
|
|
573
579
|
<Button variant="ghost" onClick={resetWizard}>{t('import.select.reselect')}</Button>
|
|
574
580
|
<Button
|
|
@@ -605,7 +611,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
|
|
|
605
611
|
setArchiveUnlockError(null)
|
|
606
612
|
}}
|
|
607
613
|
/>
|
|
608
|
-
{archiveUnlockError !== null && <ErrorBanner error={archiveUnlockError} />}
|
|
614
|
+
{archiveUnlockError !== null && <ErrorBanner error={archiveUnlockError} t={api.t} />}
|
|
609
615
|
<div className={css.actionRow}>
|
|
610
616
|
<Button variant="ghost" onClick={resetWizard}>{t('import.select.reselect')}</Button>
|
|
611
617
|
<Button
|
|
@@ -698,7 +704,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
|
|
|
698
704
|
</Button>
|
|
699
705
|
</div>
|
|
700
706
|
</Card>
|
|
701
|
-
{error !== null && <ErrorBanner error={error} onRetry={() => { void execute() }} />}
|
|
707
|
+
{error !== null && <ErrorBanner error={error} onRetry={() => { void execute() }} t={api.t} />}
|
|
702
708
|
</div>
|
|
703
709
|
)
|
|
704
710
|
}
|
|
@@ -723,7 +729,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
|
|
|
723
729
|
)}
|
|
724
730
|
{imp.skipRequested && <div className={css.hint}>{t('import.skipPending')}</div>}
|
|
725
731
|
<div className={css.hint}>{t('import.importing')}</div>
|
|
726
|
-
{error !== null && <ErrorBanner error={error} />}
|
|
732
|
+
{error !== null && <ErrorBanner error={error} t={api.t} />}
|
|
727
733
|
<ErrorList errors={imp.errors} />
|
|
728
734
|
</div>
|
|
729
735
|
)
|
|
@@ -753,7 +759,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
|
|
|
753
759
|
</div>
|
|
754
760
|
)}
|
|
755
761
|
{result.needsRestart && <Banner kind="warn">{t('report.needsRestart')}</Banner>}
|
|
756
|
-
{error !== null && <ErrorBanner error={error} />}
|
|
762
|
+
{error !== null && <ErrorBanner error={error} t={api.t} />}
|
|
757
763
|
</div>
|
|
758
764
|
)
|
|
759
765
|
}
|
|
@@ -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
|
@@ -190,10 +190,15 @@ export interface MarketStoreSlice {
|
|
|
190
190
|
myConfirmDeleteId: string | null
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
/** 快照恢复面板的切片(无敏感字段;快照列表本身可随时重载,不持久化)。
|
|
193
|
+
/** 快照恢复面板的切片(无敏感字段;快照列表本身可随时重载,不持久化)。
|
|
194
|
+
* running 为「内存切片瞬态」:切 tab 由模块级单例保留、刷新时被 toPersistedState
|
|
195
|
+
* 白名单剔除 —— 恢复是否仍在执行以宿主 RunRegistry(/runs + /progress)为权威,
|
|
196
|
+
* 刷新后经 resume() 重新发现;浏览器持久化绝不作为 destructive operation 的状态源。 */
|
|
194
197
|
export interface SnapshotsStoreSlice {
|
|
195
198
|
selectedId: string | null
|
|
196
199
|
plan: RestorePlan | null
|
|
200
|
+
/** 真实恢复执行中(瞬态;宿主 /runs 是权威来源,本字段只是镜像) */
|
|
201
|
+
running: boolean
|
|
197
202
|
report: RestoreReport | null
|
|
198
203
|
actionError: string | null
|
|
199
204
|
error: string | null
|
|
@@ -425,6 +430,7 @@ function defaultSnapshotsState(): SnapshotsStoreSlice {
|
|
|
425
430
|
return {
|
|
426
431
|
selectedId: null,
|
|
427
432
|
plan: null,
|
|
433
|
+
running: false,
|
|
428
434
|
report: null,
|
|
429
435
|
actionError: null,
|
|
430
436
|
error: null,
|
|
@@ -505,6 +511,7 @@ export function toSnapshotsStoreSlice(s: SnapshotsStoreSlice): SnapshotsStoreSli
|
|
|
505
511
|
return {
|
|
506
512
|
selectedId: s.selectedId,
|
|
507
513
|
plan: s.plan,
|
|
514
|
+
running: s.running,
|
|
508
515
|
report: s.report,
|
|
509
516
|
actionError: s.actionError,
|
|
510
517
|
error: s.error,
|
|
@@ -560,7 +567,13 @@ export function toPersistedState(state: StoreState): PersistedState {
|
|
|
560
567
|
},
|
|
561
568
|
},
|
|
562
569
|
market: state.market,
|
|
563
|
-
snapshots:
|
|
570
|
+
snapshots: {
|
|
571
|
+
...state.snapshots,
|
|
572
|
+
// 快照恢复 running 为内存切片瞬态:不落盘 —— 恢复是否仍在执行以宿主
|
|
573
|
+
// RunRegistry(/runs + /progress)为权威,刷新后由 resume() 重新发现;
|
|
574
|
+
// 持久化「running=true」会把浏览器陈旧状态误当成宿主真实状态(P1-1 原则)。
|
|
575
|
+
running: false,
|
|
576
|
+
},
|
|
564
577
|
}
|
|
565
578
|
}
|
|
566
579
|
|
|
@@ -901,7 +914,13 @@ export class RunStore {
|
|
|
901
914
|
}
|
|
902
915
|
})(),
|
|
903
916
|
market: { ...defaultMarketState(), ...parsed.market },
|
|
904
|
-
snapshots: {
|
|
917
|
+
snapshots: {
|
|
918
|
+
...defaultSnapshotsState(),
|
|
919
|
+
...parsed.snapshots,
|
|
920
|
+
// 硬性:恢复执行中为瞬态,绝不从存储恢复(即使旧载荷携带 running=true 也清空)——
|
|
921
|
+
// 是否仍有恢复在执行以宿主 /runs 为权威,resume() 会重新发现并置 true
|
|
922
|
+
running: false,
|
|
923
|
+
},
|
|
905
924
|
}
|
|
906
925
|
// 安全兜底:整体加密备份容器已解锁标志绝不从存储恢复(archiveUnlocked 必为 false)→
|
|
907
926
|
// 刷新后只要仍标记为加密容器且已越过 decrypt-archive 阶段,就强制退回重新解锁。
|
|
@@ -1037,6 +1056,25 @@ export class RunStore {
|
|
|
1037
1056
|
},
|
|
1038
1057
|
})
|
|
1039
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
|
+
}
|
|
1040
1078
|
return resumed
|
|
1041
1079
|
}
|
|
1042
1080
|
|
|
@@ -1142,7 +1180,13 @@ export class RunStore {
|
|
|
1142
1180
|
private patchProgress(kind: RunKind, state: RunState): void {
|
|
1143
1181
|
const event = mapRunProgress(kind, state)
|
|
1144
1182
|
if (kind === 'export') this.patch({ export: { progress: event } })
|
|
1145
|
-
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
|
+
}
|
|
1146
1190
|
}
|
|
1147
1191
|
|
|
1148
1192
|
/** run 完成/失败:把 RunState.result 回填 store(并镜像回控制器)。 */
|
|
@@ -1173,6 +1217,22 @@ export class RunStore {
|
|
|
1173
1217
|
}
|
|
1174
1218
|
return
|
|
1175
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
|
+
}
|
|
1176
1236
|
// import
|
|
1177
1237
|
const result = state.result as ImportResult | undefined
|
|
1178
1238
|
if (state.status === 'done' && result !== undefined && typeof result === 'object') {
|
|
@@ -1226,6 +1286,15 @@ export class RunStore {
|
|
|
1226
1286
|
error: '任务已结束或超过保留期,进度不可恢复',
|
|
1227
1287
|
},
|
|
1228
1288
|
})
|
|
1289
|
+
} else if (kind === 'restore') {
|
|
1290
|
+
// 恢复 run 被清理/丢失:复位 running(宿主侧恢复动作可能仍在执行——
|
|
1291
|
+
// 以 RunRegistry 为准,本提示只说明进度不可恢复,不臆断恢复已停止)
|
|
1292
|
+
this.patch({
|
|
1293
|
+
snapshots: {
|
|
1294
|
+
running: false,
|
|
1295
|
+
actionError: '恢复任务已结束或超过保留期,进度不可恢复',
|
|
1296
|
+
},
|
|
1297
|
+
})
|
|
1229
1298
|
} else {
|
|
1230
1299
|
this.importWizardInst?.reset()
|
|
1231
1300
|
this.patch({
|
|
@@ -98,12 +98,15 @@ function actionKindLabel(kind: string): string {
|
|
|
98
98
|
/**
|
|
99
99
|
* 从 runStore 恢复上次的快照面板状态(切 tab 回 / 刷新后挂载)。
|
|
100
100
|
* 无敏感字段;plan/report 为纯数据,可安全序列化恢复。
|
|
101
|
+
* running 来自 store 镜像(刷新后经 runStore.resume() 以宿主 /runs 为权威重新置位——
|
|
102
|
+
* 持久化白名单恒为 false,绝不把浏览器陈旧状态当成恢复执行中的依据)。
|
|
101
103
|
*/
|
|
102
104
|
function initFromStore(): PanelState {
|
|
103
105
|
const s: SnapshotsStoreSlice = runStore.getSnapshot().snapshots
|
|
104
106
|
return {
|
|
105
107
|
...initial,
|
|
106
108
|
selectedId: s.selectedId,
|
|
109
|
+
running: s.running,
|
|
107
110
|
plan: s.plan,
|
|
108
111
|
report: s.report,
|
|
109
112
|
actionError: s.actionError,
|
|
@@ -183,6 +186,10 @@ export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
|
|
|
183
186
|
if (state.selectedId === null || state.running) return
|
|
184
187
|
patch({ running: true, report: null, actionError: null })
|
|
185
188
|
setConfirmOpen(false)
|
|
189
|
+
// m3/P1-1:宿主侧权威防重(/restore 经 RunRegistry 登记,同 kind running → 409);
|
|
190
|
+
// 前端 running 只是 UX 镜像。watchRunning 轮询宿主 /runs + /progress——
|
|
191
|
+
// 切 tab(卸载)后轮询继续,完成/失败经 applySettled 写入 store,切回 initFromStore 恢复。
|
|
192
|
+
runStore.watchRunning('restore', 500)
|
|
186
193
|
api.restoreSnapshot(state.selectedId, false).then(
|
|
187
194
|
(res) => { patch({ running: false, report: res.report ?? null }) },
|
|
188
195
|
(err) => {
|
|
@@ -191,7 +198,7 @@ export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
|
|
|
191
198
|
actionError: err instanceof Error ? err.message : String(err),
|
|
192
199
|
})
|
|
193
200
|
},
|
|
194
|
-
)
|
|
201
|
+
).finally(() => { runStore.stopRunWatch('restore') })
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
const summary = (): string => {
|
|
@@ -376,12 +383,17 @@ function BackupScheduleCard({ api, t }: { api: ConfigManagerApi; t: TranslateNS<
|
|
|
376
383
|
const [lastRunDetail, setLastRunDetail] = useState<string | null>(null)
|
|
377
384
|
const [flash, setFlash] = useState<string | null>(null)
|
|
378
385
|
const [actionError, setActionError] = useState<string | null>(null)
|
|
386
|
+
/** 挂载守卫:切 tab 卸载后异步回调只更新 store(草稿),不再 setState(React 18 无告警但浪费) */
|
|
387
|
+
const mountedRef = useRef(true)
|
|
388
|
+
|
|
389
|
+
useEffect(() => () => { mountedRef.current = false }, [])
|
|
379
390
|
|
|
380
391
|
const load = (): void => {
|
|
381
392
|
setStatus('loading')
|
|
382
393
|
setError(null)
|
|
383
394
|
api.backupSchedule().then(
|
|
384
395
|
(schedule) => {
|
|
396
|
+
if (!mountedRef.current) return
|
|
385
397
|
setSaved(schedule)
|
|
386
398
|
// 有未保存草稿(切 tab 回来)则保留,否则以宿主配置为权威
|
|
387
399
|
setDraft(runStore.getSnapshot().snapshots.backupDraft ?? { enabled: schedule.enabled, interval: schedule.interval })
|
|
@@ -390,6 +402,7 @@ function BackupScheduleCard({ api, t }: { api: ConfigManagerApi; t: TranslateNS<
|
|
|
390
402
|
setStatus('ready')
|
|
391
403
|
},
|
|
392
404
|
(err) => {
|
|
405
|
+
if (!mountedRef.current) return
|
|
393
406
|
setStatus('error')
|
|
394
407
|
setError(err instanceof Error ? err.message : String(err))
|
|
395
408
|
},
|
|
@@ -415,15 +428,18 @@ function BackupScheduleCard({ api, t }: { api: ConfigManagerApi; t: TranslateNS<
|
|
|
415
428
|
setActionError(null)
|
|
416
429
|
api.saveBackupSchedule(parsed.value).then(
|
|
417
430
|
(schedule) => {
|
|
431
|
+
// 宿主已保存:无论面板是否仍挂载都清 store 草稿(否则切回会显示陈旧未保存态)
|
|
432
|
+
runStore.patch({ snapshots: { backupDraft: null } })
|
|
433
|
+
if (!mountedRef.current) return
|
|
418
434
|
setSaved(schedule)
|
|
419
435
|
setDraft({ enabled: schedule.enabled, interval: schedule.interval })
|
|
420
436
|
setLastRun(schedule.lastRunStatus)
|
|
421
437
|
setLastRunDetail(formatRunTime(schedule.lastRunAt))
|
|
422
|
-
runStore.patch({ snapshots: { backupDraft: null } })
|
|
423
438
|
setSaving(false)
|
|
424
439
|
setFlash(t('backupSchedule.saved'))
|
|
425
440
|
},
|
|
426
441
|
(err) => {
|
|
442
|
+
if (!mountedRef.current) return
|
|
427
443
|
setSaving(false)
|
|
428
444
|
setActionError(err instanceof Error ? err.message : String(err))
|
|
429
445
|
},
|
|
@@ -437,6 +453,7 @@ function BackupScheduleCard({ api, t }: { api: ConfigManagerApi; t: TranslateNS<
|
|
|
437
453
|
setActionError(null)
|
|
438
454
|
api.runBackupNow().then(
|
|
439
455
|
(res) => {
|
|
456
|
+
if (!mountedRef.current) return
|
|
440
457
|
setSaved(res.schedule)
|
|
441
458
|
setLastRun(res.run.status)
|
|
442
459
|
setLastRunDetail(res.run.zip !== undefined && res.run.zip !== ''
|
|
@@ -445,6 +462,7 @@ function BackupScheduleCard({ api, t }: { api: ConfigManagerApi; t: TranslateNS<
|
|
|
445
462
|
setRunning(false)
|
|
446
463
|
},
|
|
447
464
|
(err) => {
|
|
465
|
+
if (!mountedRef.current) return
|
|
448
466
|
setRunning(false)
|
|
449
467
|
setActionError(err instanceof Error ? err.message : String(err))
|
|
450
468
|
},
|
package/src/core/restore.ts
CHANGED
|
@@ -94,6 +94,9 @@ export interface RestoreOptions {
|
|
|
94
94
|
pluginUninstaller?: (
|
|
95
95
|
name: string, profileDir: string, profile: string,
|
|
96
96
|
) => Promise<{ ok: boolean; message?: string }>;
|
|
97
|
+
/** 每项动作执行回调(宿主路由埋点:更新 RunRegistry 进度;index/1-based、total=计划动作数、
|
|
98
|
+
* detail=动作描述。dry-run/planRestore 不触发) */
|
|
99
|
+
onAction?: (info: { index: number; total: number; detail: string }) => void;
|
|
97
100
|
/** 消息翻译器(缺省 zh;宿主按 DSH 应用语言注入) */
|
|
98
101
|
msg?: MsgFunc;
|
|
99
102
|
}
|
|
@@ -494,7 +497,11 @@ export async function restore(opts: RestoreOptions): Promise<RestoreReport> {
|
|
|
494
497
|
};
|
|
495
498
|
|
|
496
499
|
let seq = 0;
|
|
500
|
+
const total = plan.actions.length;
|
|
501
|
+
let actionIndex = 0;
|
|
497
502
|
for (const action of plan.actions) {
|
|
503
|
+
actionIndex += 1;
|
|
504
|
+
opts.onAction?.({ index: actionIndex, total, detail: action.description });
|
|
498
505
|
try {
|
|
499
506
|
switch (action.kind) {
|
|
500
507
|
case 'hostFileRestore': {
|
|
@@ -118,6 +118,46 @@ test('appendLog: 日志行数封顶 MAX_RUN_LOG_LINES(超限截断保留最新
|
|
|
118
118
|
assert.equal(state?.log[state!.log.length - 1], `line-${MAX_RUN_LOG_LINES + 9}`)
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
+
test('appendLog: 不可变追加——每次 append 换新数组引用(React memo 感知新行;封顶后长度恒定但引用仍变)', () => {
|
|
122
|
+
const reg = new RunRegistry()
|
|
123
|
+
const run = reg.register('import')
|
|
124
|
+
const initial = reg.get(run.runId)!.log
|
|
125
|
+
reg.appendLog(run.runId, 'line-1')
|
|
126
|
+
const second = reg.get(run.runId)!.log
|
|
127
|
+
assert.notEqual(second, initial, 'append 必须生成新数组引用(不得原地 push)')
|
|
128
|
+
assert.deepEqual(second, ['line-1'])
|
|
129
|
+
|
|
130
|
+
// 500 行封顶后:长度恒定(memo 按长度比较会漏),但每次 append 引用必变
|
|
131
|
+
const capRun = reg.register('export')
|
|
132
|
+
const refs = new Set<unknown[]>()
|
|
133
|
+
for (let i = 0; i < MAX_RUN_LOG_LINES + 5; i++) {
|
|
134
|
+
reg.appendLog(capRun.runId, `line-${i}`)
|
|
135
|
+
refs.add(reg.get(capRun.runId)!.log)
|
|
136
|
+
}
|
|
137
|
+
assert.equal(reg.get(capRun.runId)!.log.length, MAX_RUN_LOG_LINES)
|
|
138
|
+
assert.equal(refs.size, MAX_RUN_LOG_LINES + 5, '每次 append 都是新引用(含封顶后)——前端以引用比较不会漏渲染')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('register: 快照恢复(restore)同 kind 进行中拒绝(P1-1 并发恢复防护)', () => {
|
|
142
|
+
const reg = new RunRegistry()
|
|
143
|
+
const first = reg.register('restore')
|
|
144
|
+
assert.throws(
|
|
145
|
+
() => reg.register('restore'),
|
|
146
|
+
(err: unknown) => {
|
|
147
|
+
assert.ok(err instanceof RunConflictError)
|
|
148
|
+
assert.equal((err as RunConflictError).runId, first.runId)
|
|
149
|
+
return true
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
// 恢复中允许导出(不同 kind 互不阻塞)
|
|
153
|
+
const exp = reg.register('export')
|
|
154
|
+
assert.equal(exp.kind, 'export')
|
|
155
|
+
// 完成后的 restore 不阻塞新 restore
|
|
156
|
+
reg.finish(first.runId, {})
|
|
157
|
+
const second = reg.register('restore')
|
|
158
|
+
assert.notEqual(second.runId, first.runId)
|
|
159
|
+
})
|
|
160
|
+
|
|
121
161
|
test('finish/fail: 状态与 result/error 落账', () => {
|
|
122
162
|
const reg = new RunRegistry()
|
|
123
163
|
const done = reg.register('export')
|