dsh-config-manager 0.1.12 → 0.1.13
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 +30 -0
- package/README.zh-CN.md +30 -0
- package/lib/adapters/test-helpers.d.ts +4 -2
- package/lib/adapters/test-helpers.js +11 -3
- package/lib/adapters/test-helpers.js.map +1 -1
- package/lib/cli/index.d.ts +44 -0
- package/lib/cli/index.js +230 -0
- package/lib/cli/index.js.map +1 -0
- package/lib/client.d.ts +260 -5
- package/lib/client.js +2519 -820
- package/lib/client.js.map +1 -1
- package/lib/core/analyzer.d.ts +18 -1
- package/lib/core/analyzer.js +29 -0
- package/lib/core/analyzer.js.map +1 -1
- package/lib/core/backup.d.ts +3 -1
- package/lib/core/backup.js +70 -0
- package/lib/core/backup.js.map +1 -1
- package/lib/core/exporter.d.ts +11 -0
- package/lib/core/exporter.js +11 -0
- package/lib/core/exporter.js.map +1 -1
- package/lib/core/importer.d.ts +3 -0
- package/lib/core/importer.js +1 -0
- package/lib/core/importer.js.map +1 -1
- package/lib/core/index.d.ts +1 -0
- package/lib/core/index.js +1 -0
- package/lib/core/index.js.map +1 -1
- package/lib/core/restore.d.ts +92 -0
- package/lib/core/restore.js +505 -0
- package/lib/core/restore.js.map +1 -0
- package/lib/core/run-registry.d.ts +65 -0
- package/lib/core/run-registry.js +120 -0
- package/lib/core/run-registry.js.map +1 -0
- package/lib/core/types.d.ts +20 -0
- package/lib/core/types.js.map +1 -1
- package/lib/index.d.ts +45 -0
- package/lib/index.js +442 -8
- package/lib/index.js.map +1 -1
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +47 -0
- package/lib/sync/fs.js.map +1 -0
- package/lib/sync/git/git-transport.d.ts +87 -0
- package/lib/sync/git/git-transport.js +333 -0
- package/lib/sync/git/git-transport.js.map +1 -0
- package/lib/sync/layout.d.ts +29 -0
- package/lib/sync/layout.js +166 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/sync-config.d.ts +16 -0
- package/lib/sync/sync-config.js +67 -0
- package/lib/sync/sync-config.js.map +1 -0
- package/lib/sync/sync-engine.d.ts +96 -0
- package/lib/sync/sync-engine.js +236 -0
- package/lib/sync/sync-engine.js.map +1 -0
- package/lib/sync/sync-state.d.ts +31 -0
- package/lib/sync/sync-state.js +103 -0
- package/lib/sync/sync-state.js.map +1 -0
- package/lib/sync/transport.d.ts +50 -0
- package/lib/sync/transport.js +22 -0
- package/lib/sync/transport.js.map +1 -0
- package/package.json +4 -1
- package/src/adapters/test-helpers.ts +10 -3
- package/src/cli/index.ts +278 -0
- package/src/client/ConfigManagerSection.tsx +49 -14
- package/src/client/api.ts +53 -5
- package/src/client/client-types.ts +0 -6
- package/src/client/common/ProgressBar.tsx +31 -19
- package/src/client/common/progress-view-edge.test.ts +60 -0
- package/src/client/common/progress-view.test.ts +79 -0
- package/src/client/common/progress-view.ts +94 -0
- package/src/client/config-manager.module.css +99 -20
- package/src/client/export/ExportView.tsx +63 -49
- package/src/client/import/ImportWizardView.tsx +107 -66
- package/src/client/index.ts +22 -0
- package/src/client/locales.ts +53 -0
- package/src/client/run-store.test.ts +500 -0
- package/src/client/run-store.ts +772 -0
- package/src/client/snapshots/SnapshotsPanel.tsx +240 -0
- package/src/client/sync/SyncSettingsView.tsx +261 -0
- package/src/client/sync/sync-api.test.ts +117 -0
- package/src/client/sync/sync-api.ts +132 -0
- package/src/client/sync/sync-locales.ts +77 -0
- package/src/client/sync/sync-view.test.ts +211 -0
- package/src/client/sync/sync-view.ts +202 -0
- package/src/core/analyzer.ts +50 -1
- package/src/core/backup.ts +78 -2
- package/src/core/exporter.ts +21 -0
- package/src/core/importer.ts +4 -0
- package/src/core/index.ts +5 -0
- package/src/core/restore.ts +587 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +139 -0
- package/src/core/run-registry.ts +166 -0
- package/src/core/smoke.test.ts +5 -0
- package/src/core/types.ts +22 -0
- package/src/index.ts +476 -8
- package/src/security/security.test.ts +5 -0
- package/src/sync/fs.ts +46 -0
- package/src/sync/git/git-transport.test.ts +456 -0
- package/src/sync/git/git-transport.ts +390 -0
- package/src/sync/layout.test.ts +226 -0
- package/src/sync/layout.ts +183 -0
- package/src/sync/sync-config.ts +72 -0
- package/src/sync/sync-engine.test.ts +299 -0
- package/src/sync/sync-engine.ts +318 -0
- package/src/sync/sync-state.test.ts +118 -0
- package/src/sync/sync-state.ts +123 -0
- package/src/sync/transport.test.ts +110 -0
- package/src/sync/transport.ts +73 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m2: 模块级 run/UI store —— dsh-config-manager 浏览器半的状态中枢。
|
|
3
|
+
*
|
|
4
|
+
* 解决的问题(验收 m2-state / m2-refresh / m2-resume):
|
|
5
|
+
* - 切 tab / 关面板重开:模块级单例存活于当前 JS 上下文,ExportFlow /
|
|
6
|
+
* ImportWizard 控制器实例与 UI 状态**不重建**(m2-state);
|
|
7
|
+
* - 页面刷新:非敏感状态经 sessionStorage(键 dsh.cfgMgr.state.v1)
|
|
8
|
+
* 序列化/反序列化恢复(m2-refresh);敏感字段
|
|
9
|
+
* password / passwordConfirm / secretInputs **只存在内存**,序列化白名单
|
|
10
|
+
* 显式剔除 —— 刷新后自动清空,secrets 阶段要求重输;
|
|
11
|
+
* - 进行中 run:视图挂载时经 GET /runs 找回活跃 runId,轮询 GET /progress
|
|
12
|
+
* 直到完成/失败,把 RunState.result 回填到 store(m2-resume)。服务端在
|
|
13
|
+
* 刷新/关面板期间继续执行,本 store 只负责重新订阅进度。
|
|
14
|
+
*
|
|
15
|
+
* 控制器 rehydrate 说明:ImportWizard 没有公开的 hydrate 入口(m2 约束为只改
|
|
16
|
+
* src/client/、不动 src/ui/),刷新恢复需要把持久化的非敏感快照写回控制器
|
|
17
|
+
* 私有字段。writeWizardSnapshot() 是唯一的受控访问点:类型层 private 只是
|
|
18
|
+
* 编译期约束(运行时是普通属性),所有写入值都来自序列化白名单(已剔除
|
|
19
|
+
* 密码/密钥/secretInputs),且只在 load / hydrate / resume-settle 时调用。
|
|
20
|
+
*
|
|
21
|
+
* 安全约束:
|
|
22
|
+
* - toPersistedState() 用解构白名单剔除敏感字段,即使未来往 LiveState 加字段,
|
|
23
|
+
* 未显式放行也不会落入 sessionStorage;
|
|
24
|
+
* - runId 为 32-hex 不可猜标识,可安全持久化(/runs + /progress 的查询键)。
|
|
25
|
+
*/
|
|
26
|
+
import { ConflictCollector } from '../ui/conflict-view.ts'
|
|
27
|
+
import { DEFAULT_CATEGORIES, ExportFlow } from '../ui/export-flow.ts'
|
|
28
|
+
import type { ExportRunResult } from '../ui/export-flow.ts'
|
|
29
|
+
import { ImportWizard } from '../ui/import-wizard.ts'
|
|
30
|
+
import { renderExportReport } from '../ui/report.ts'
|
|
31
|
+
import type { ImportStep } from '../ui/types.ts'
|
|
32
|
+
import type { RunProgress } from './common/progress-view.ts'
|
|
33
|
+
import type {
|
|
34
|
+
GlobalConflictStrategy, ImportAnalysis, ImportDecisions, ImportPlan, ImportResult,
|
|
35
|
+
ItemResolution, PathMapping,
|
|
36
|
+
} from '../core/types.ts'
|
|
37
|
+
import type { RunKind, RunState } from '../core/run-registry.ts'
|
|
38
|
+
import type { Manifest, SectionId } from '../schema/types.ts'
|
|
39
|
+
import type { ConfigManagerApi } from './api.ts'
|
|
40
|
+
|
|
41
|
+
/* ---------------------------------------------------------------- 基础类型 */
|
|
42
|
+
|
|
43
|
+
/** 主视图(ConfigManagerSection 的 tab 状态)。 */
|
|
44
|
+
export type MainView = 'export' | 'import'
|
|
45
|
+
|
|
46
|
+
/** 导出模式。 */
|
|
47
|
+
export type ExportMode = 'quick' | 'custom'
|
|
48
|
+
|
|
49
|
+
/** 存储抽象(浏览器 sessionStorage / 测试 mock / 无存储)。 */
|
|
50
|
+
export interface StoreStorage {
|
|
51
|
+
getItem(key: string): string | null
|
|
52
|
+
setItem(key: string, value: string): void
|
|
53
|
+
removeItem(key: string): void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** sessionStorage 键。 */
|
|
57
|
+
export const STATE_KEY = 'dsh.cfgMgr.state.v1'
|
|
58
|
+
|
|
59
|
+
/** Custom 模式的初始勾选 = 推荐分区(可再调整) */
|
|
60
|
+
export function defaultCustomSelection(): SectionId[] {
|
|
61
|
+
return DEFAULT_CATEGORIES
|
|
62
|
+
.filter((c) => c.defaultIncluded)
|
|
63
|
+
.map((c) => c.id)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ------------------------------------------------- 持久化(非敏感)状态形状 */
|
|
67
|
+
|
|
68
|
+
/** 导出视图的持久化切片(不含 password/passwordConfirm)。 */
|
|
69
|
+
export interface PersistedExportState {
|
|
70
|
+
mode: ExportMode
|
|
71
|
+
selection: SectionId[]
|
|
72
|
+
includeSecrets: boolean
|
|
73
|
+
running: boolean
|
|
74
|
+
progress: RunProgress | null
|
|
75
|
+
result: ExportRunResult | null
|
|
76
|
+
/** 错误消息(已 redact 的文本;Error 对象不可 JSON 序列化,统一转字符串) */
|
|
77
|
+
error: string | null
|
|
78
|
+
downloaded: boolean
|
|
79
|
+
/** 最近一次导出 run id(/runs + /progress 重新订阅用;32-hex 不可猜) */
|
|
80
|
+
runId: string | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 导入向导的持久化切片(不含 secretInputs)。 */
|
|
84
|
+
export interface PersistedImportState {
|
|
85
|
+
step: ImportStep
|
|
86
|
+
zipPath: string | null
|
|
87
|
+
analysis: ImportAnalysis | null
|
|
88
|
+
plan: ImportPlan | null
|
|
89
|
+
result: ImportResult | null
|
|
90
|
+
rollbackOnError: boolean
|
|
91
|
+
errors: string[]
|
|
92
|
+
/** UI 层流程阶段(wizard.step 之外的页面;见 src/ui/flow.ts) */
|
|
93
|
+
phase: FlowPhase
|
|
94
|
+
conflictStrategy: GlobalConflictStrategy
|
|
95
|
+
conflictResolutions: Record<string, ItemResolution>
|
|
96
|
+
pathMappings: PathMapping[]
|
|
97
|
+
uploading: boolean
|
|
98
|
+
running: boolean
|
|
99
|
+
progress: RunProgress | null
|
|
100
|
+
error: string | null
|
|
101
|
+
runId: string | null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 刷新恢复的顶层持久化状态(v1 结构版本)。 */
|
|
105
|
+
export interface PersistedState {
|
|
106
|
+
v: 1
|
|
107
|
+
view: MainView
|
|
108
|
+
export: PersistedExportState
|
|
109
|
+
import: PersistedImportState
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* --------------------------------------- 运行时状态(含仅内存的敏感字段) */
|
|
113
|
+
|
|
114
|
+
/** 导出运行时状态 = 持久化字段 + 仅内存的密码字段。 */
|
|
115
|
+
export interface ExportLiveState extends PersistedExportState {
|
|
116
|
+
/** 加密密码(仅内存,绝不写入 sessionStorage;刷新后清空) */
|
|
117
|
+
password: string
|
|
118
|
+
passwordConfirm: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 导入运行时状态 = 持久化字段 + 仅内存的敏感/实例字段。 */
|
|
122
|
+
export interface ImportLiveState extends PersistedImportState {
|
|
123
|
+
/** 秘密补录值(仅内存,绝不写入 sessionStorage;刷新后清空、secrets 阶段重输) */
|
|
124
|
+
secretInputs: Record<string, string>
|
|
125
|
+
/** 冲突决策收集器实例(仅内存;刷新后由 plan + conflictResolutions 重建) */
|
|
126
|
+
conflictCollector: ConflictCollector | null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** store 的完整运行时快照(getSnapshot 返回;含敏感字段,仅供组件读取)。 */
|
|
130
|
+
export interface StoreState {
|
|
131
|
+
v: 1
|
|
132
|
+
view: MainView
|
|
133
|
+
export: ExportLiveState
|
|
134
|
+
import: ImportLiveState
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** patch 的输入形状(浅合并对应切片)。 */
|
|
138
|
+
export interface StorePatch {
|
|
139
|
+
view?: MainView
|
|
140
|
+
export?: Partial<ExportLiveState>
|
|
141
|
+
import?: Partial<ImportLiveState>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 向导 step 的类型别名(与 src/ui/types.ts 的 ImportStep 保持一致)。 */
|
|
145
|
+
type ImportWizardStep = ImportStep
|
|
146
|
+
|
|
147
|
+
/** UI 层流程阶段(与 src/ui/flow.ts 的 FlowPhase 保持一致)。 */
|
|
148
|
+
type FlowPhase = 'preview' | 'conflicts' | 'path-mapping' | 'secrets' | 'confirm'
|
|
149
|
+
|
|
150
|
+
/* ------------------------------------------------------------- 默认值 */
|
|
151
|
+
|
|
152
|
+
function defaultExportState(): ExportLiveState {
|
|
153
|
+
return {
|
|
154
|
+
mode: 'quick',
|
|
155
|
+
selection: defaultCustomSelection(),
|
|
156
|
+
includeSecrets: false,
|
|
157
|
+
password: '',
|
|
158
|
+
passwordConfirm: '',
|
|
159
|
+
running: false,
|
|
160
|
+
progress: null,
|
|
161
|
+
result: null,
|
|
162
|
+
error: null,
|
|
163
|
+
downloaded: false,
|
|
164
|
+
runId: null,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function defaultImportState(): ImportLiveState {
|
|
169
|
+
return {
|
|
170
|
+
step: 'select',
|
|
171
|
+
zipPath: null,
|
|
172
|
+
analysis: null,
|
|
173
|
+
plan: null,
|
|
174
|
+
result: null,
|
|
175
|
+
rollbackOnError: true,
|
|
176
|
+
errors: [],
|
|
177
|
+
phase: 'preview',
|
|
178
|
+
conflictStrategy: 'merge',
|
|
179
|
+
conflictResolutions: {},
|
|
180
|
+
pathMappings: [],
|
|
181
|
+
secretInputs: {},
|
|
182
|
+
uploading: false,
|
|
183
|
+
running: false,
|
|
184
|
+
progress: null,
|
|
185
|
+
error: null,
|
|
186
|
+
runId: null,
|
|
187
|
+
conflictCollector: null,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function defaultState(): StoreState {
|
|
192
|
+
return {
|
|
193
|
+
v: 1,
|
|
194
|
+
view: 'export',
|
|
195
|
+
export: defaultExportState(),
|
|
196
|
+
import: defaultImportState(),
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ------------------------------------------------- 序列化白名单(安全关键) */
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 持久化白名单:解构剔除敏感字段(password/passwordConfirm/secretInputs)
|
|
204
|
+
* 与不可序列化的实例字段(conflictCollector),其余原样落入 sessionStorage。
|
|
205
|
+
* 这是 sessionStorage 的唯一写入路径 —— 敏感值在此被硬性隔离。
|
|
206
|
+
*/
|
|
207
|
+
export function toPersistedState(state: StoreState): PersistedState {
|
|
208
|
+
const { password: _password, passwordConfirm: _passwordConfirm, ...exportRest } = state.export
|
|
209
|
+
const { secretInputs: _secretInputs, conflictCollector: _conflictCollector, ...importRest } = state.import
|
|
210
|
+
return { v: 1, view: state.view, export: exportRest, import: importRest }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 解析 + 轻量校验持久化状态;损坏/版本不符返回 null(调用方回退默认并清键)。 */
|
|
214
|
+
export function parsePersistedState(raw: string): PersistedState | null {
|
|
215
|
+
let parsed: unknown
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(raw)
|
|
218
|
+
} catch {
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
222
|
+
const p = parsed as Record<string, unknown>
|
|
223
|
+
if (p['v'] !== 1) return null
|
|
224
|
+
const view = p['view']
|
|
225
|
+
if (view !== 'export' && view !== 'import') return null
|
|
226
|
+
const exp = p['export']
|
|
227
|
+
const imp = p['import']
|
|
228
|
+
if (typeof exp !== 'object' || exp === null || typeof imp !== 'object' || imp === null) return null
|
|
229
|
+
return { v: 1, view, export: exp as PersistedExportState, import: imp as PersistedImportState }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ----------------------------------------------------- 控制器 rehydrate */
|
|
233
|
+
|
|
234
|
+
/** ImportWizard 私有字段的镜像形状(仅供 writeWizardSnapshot 受控写入)。 */
|
|
235
|
+
interface WizardInternals {
|
|
236
|
+
step: ImportWizardStep
|
|
237
|
+
zipPath: string | null
|
|
238
|
+
analysis: ImportAnalysis | null
|
|
239
|
+
plan: ImportPlan | null
|
|
240
|
+
result: ImportResult | null
|
|
241
|
+
rollbackOnError: boolean
|
|
242
|
+
errors: string[]
|
|
243
|
+
decisions: ImportDecisions
|
|
244
|
+
secretInputs: Record<string, string>
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 把 store 的导入快照写回 ImportWizard 控制器私有字段(受控 rehydrate)。
|
|
249
|
+
* - 只写非敏感字段;secretInputs 恒置空 —— 刷新后 secrets 阶段要求重输;
|
|
250
|
+
* - 仅在 load / hydrate / resume-settle 时调用,不参与正常交互路径。
|
|
251
|
+
*/
|
|
252
|
+
function writeWizardSnapshot(wizard: ImportWizard, imp: ImportLiveState): void {
|
|
253
|
+
const internals = wizard as unknown as WizardInternals
|
|
254
|
+
internals.step = imp.step
|
|
255
|
+
internals.zipPath = imp.zipPath
|
|
256
|
+
internals.analysis = imp.analysis
|
|
257
|
+
internals.plan = imp.plan
|
|
258
|
+
internals.result = imp.result
|
|
259
|
+
internals.rollbackOnError = imp.rollbackOnError
|
|
260
|
+
internals.errors = [...imp.errors]
|
|
261
|
+
internals.decisions = {
|
|
262
|
+
strategy: imp.conflictStrategy,
|
|
263
|
+
resolutions: { ...imp.conflictResolutions },
|
|
264
|
+
pathMappings: imp.pathMappings.map((m) => ({ ...m, appliesTo: [...m.appliesTo] })),
|
|
265
|
+
}
|
|
266
|
+
internals.secretInputs = {}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 由 plan + 已持久化的决策重建 ConflictCollector(刷新恢复用)。 */
|
|
270
|
+
export function rebuildConflictCollector(
|
|
271
|
+
plan: ImportPlan,
|
|
272
|
+
resolutions: Record<string, ItemResolution>,
|
|
273
|
+
): ConflictCollector {
|
|
274
|
+
const collector = new ConflictCollector(plan)
|
|
275
|
+
for (const [id, resolution] of Object.entries(resolutions)) {
|
|
276
|
+
collector.resolve(id, resolution)
|
|
277
|
+
}
|
|
278
|
+
return collector
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* RunState → RunProgress(m3 轮询进度回填)。
|
|
283
|
+
* - step/total = 内部计数(百分比条按 item/itemTotal 推进);
|
|
284
|
+
* - section/sectionTotal 与 item/itemTotal 单独保留给分区徽章/内部计数徽章;
|
|
285
|
+
* - detail = 当前项名(导出时恒为分区名,ProgressBar 侧会去冗余)。
|
|
286
|
+
*/
|
|
287
|
+
function mapRunProgress(kind: RunKind, state: RunState): RunProgress {
|
|
288
|
+
return {
|
|
289
|
+
stage: kind === 'export' ? 'exporting' : 'executing',
|
|
290
|
+
detail: state.detail ?? undefined,
|
|
291
|
+
step: state.item ?? undefined,
|
|
292
|
+
total: state.itemTotal ?? undefined,
|
|
293
|
+
section: state.section,
|
|
294
|
+
sectionTotal: state.sectionTotal,
|
|
295
|
+
item: state.item,
|
|
296
|
+
itemTotal: state.itemTotal,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* ------------------------------------------------------------------- store */
|
|
301
|
+
|
|
302
|
+
export interface RunStoreOptions {
|
|
303
|
+
/** 存储提供者;缺省 = window.sessionStorage(Node/无 window 时为 null) */
|
|
304
|
+
storage?: StoreStorage | null
|
|
305
|
+
/** 进度轮询间隔(测试注入小值;缺省 1000ms) */
|
|
306
|
+
pollIntervalMs?: number
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function defaultStorage(): StoreStorage | null {
|
|
310
|
+
if (typeof window === 'undefined') return null
|
|
311
|
+
try {
|
|
312
|
+
return window.sessionStorage ?? null
|
|
313
|
+
} catch {
|
|
314
|
+
return null
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 模块级单例 store:控制器实例 + React UI 状态 + sessionStorage 恢复。
|
|
320
|
+
* 最小接口:subscribe / getSnapshot / load / save / patch / syncWizard /
|
|
321
|
+
* exportFlow / importWizard / resume / stopResume。
|
|
322
|
+
*/
|
|
323
|
+
export class RunStore {
|
|
324
|
+
private readonly storage: StoreStorage | null
|
|
325
|
+
private readonly pollIntervalMs: number
|
|
326
|
+
private readonly listeners = new Set<() => void>()
|
|
327
|
+
private state: StoreState
|
|
328
|
+
private exportFlowInst: ExportFlow | null = null
|
|
329
|
+
private importWizardInst: ImportWizard | null = null
|
|
330
|
+
private apiRef: ConfigManagerApi | null = null
|
|
331
|
+
/** 正在轮询的 runId 集合(stopResume 清空;防重复订阅) */
|
|
332
|
+
private readonly polling = new Set<string>()
|
|
333
|
+
/** 每个 runId 至多一个挂起的轮询定时器(fire 后由下一次调度覆盖) */
|
|
334
|
+
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
335
|
+
/** 正在「发现进行中 run」的 kind 集合(watchRunning 活动标记;stopRunWatch/stopResume 清空) */
|
|
336
|
+
private readonly watchActive = new Set<RunKind>()
|
|
337
|
+
|
|
338
|
+
constructor(opts: RunStoreOptions = {}) {
|
|
339
|
+
this.storage = opts.storage !== undefined ? opts.storage : defaultStorage()
|
|
340
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? 1000
|
|
341
|
+
this.state = defaultState()
|
|
342
|
+
this.load()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ------------------------------------------------------------- 订阅 */
|
|
346
|
+
|
|
347
|
+
/** 订阅 store 变化(useSyncExternalStore 的 subscribe)。 */
|
|
348
|
+
subscribe(listener: () => void): () => void {
|
|
349
|
+
this.listeners.add(listener)
|
|
350
|
+
return () => {
|
|
351
|
+
this.listeners.delete(listener)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** 当前完整快照(引用稳定:仅在 patch 后替换,适合 useSyncExternalStore)。 */
|
|
356
|
+
getSnapshot(): StoreState {
|
|
357
|
+
return this.state
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private notify(): void {
|
|
361
|
+
for (const listener of this.listeners) listener()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/* ------------------------------------------------------- 状态更新 */
|
|
365
|
+
|
|
366
|
+
/** 浅合并补丁并立即持久化(保存走白名单,敏感字段不落盘)。 */
|
|
367
|
+
patch(patchObj: StorePatch): void {
|
|
368
|
+
this.state = {
|
|
369
|
+
...this.state,
|
|
370
|
+
view: patchObj.view ?? this.state.view,
|
|
371
|
+
export: { ...this.state.export, ...patchObj.export },
|
|
372
|
+
import: { ...this.state.import, ...patchObj.import },
|
|
373
|
+
}
|
|
374
|
+
this.notify()
|
|
375
|
+
this.save()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 把 ImportWizard 控制器的 snapshot() 镜像进 store(wizard 动作后调用)。 */
|
|
379
|
+
syncWizard(): void {
|
|
380
|
+
const wizard = this.importWizardInst
|
|
381
|
+
if (wizard === null) return
|
|
382
|
+
const snap = wizard.snapshot()
|
|
383
|
+
this.patch({
|
|
384
|
+
import: {
|
|
385
|
+
step: snap.step,
|
|
386
|
+
zipPath: snap.zipPath,
|
|
387
|
+
analysis: snap.analysis,
|
|
388
|
+
plan: snap.plan,
|
|
389
|
+
result: snap.result,
|
|
390
|
+
rollbackOnError: snap.rollbackOnError,
|
|
391
|
+
errors: snap.errors,
|
|
392
|
+
},
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* ------------------------------------------------------ 持久化 load/save */
|
|
397
|
+
|
|
398
|
+
/** 从存储恢复非敏感状态(构造时调用一次;损坏数据回退默认并清键)。 */
|
|
399
|
+
load(): void {
|
|
400
|
+
if (this.storage === null) return
|
|
401
|
+
let raw: string | null = null
|
|
402
|
+
try {
|
|
403
|
+
raw = this.storage.getItem(STATE_KEY)
|
|
404
|
+
} catch {
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
if (raw === null || raw === '') {
|
|
408
|
+
this.state = defaultState()
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
const parsed = parsePersistedState(raw)
|
|
412
|
+
if (parsed === null) {
|
|
413
|
+
// 损坏/版本不符:清掉脏键,回退默认
|
|
414
|
+
try {
|
|
415
|
+
this.storage.removeItem(STATE_KEY)
|
|
416
|
+
} catch {
|
|
417
|
+
// 忽略存储错误
|
|
418
|
+
}
|
|
419
|
+
this.state = defaultState()
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
this.applyPersisted(parsed)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** 把解析后的持久化状态合并进运行时状态;敏感字段强制回到默认(清空)。 */
|
|
426
|
+
private applyPersisted(parsed: PersistedState): void {
|
|
427
|
+
this.state = {
|
|
428
|
+
v: 1,
|
|
429
|
+
view: parsed.view,
|
|
430
|
+
export: {
|
|
431
|
+
...defaultExportState(),
|
|
432
|
+
...parsed.export,
|
|
433
|
+
// 硬性:密码/确认密码绝不从存储恢复
|
|
434
|
+
password: '',
|
|
435
|
+
passwordConfirm: '',
|
|
436
|
+
},
|
|
437
|
+
import: {
|
|
438
|
+
...defaultImportState(),
|
|
439
|
+
...parsed.import,
|
|
440
|
+
// 硬性:秘密补录值绝不从存储恢复;collector 由 plan+决策重建
|
|
441
|
+
secretInputs: {},
|
|
442
|
+
conflictCollector: null,
|
|
443
|
+
},
|
|
444
|
+
}
|
|
445
|
+
// 安全兜底:confirm 阶段若仍缺必填 secret(刷新后 secretInputs 必为空),
|
|
446
|
+
// 强制退回 secrets 阶段要求重输(验收 m2-refresh 的「secrets 阶段要求重输」)。
|
|
447
|
+
const missing = this.state.import.plan?.missingSecrets ?? []
|
|
448
|
+
if (this.state.import.phase === 'confirm' && missing.length > 0) {
|
|
449
|
+
this.state.import.phase = 'secrets'
|
|
450
|
+
}
|
|
451
|
+
// 冲突阶段:由恢复后的 plan + 决策重建 collector(刷新后实例必然丢失)
|
|
452
|
+
const imp = this.state.import
|
|
453
|
+
if (imp.phase === 'conflicts' && imp.plan !== null) {
|
|
454
|
+
imp.conflictCollector = rebuildConflictCollector(imp.plan, imp.conflictResolutions)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 把非敏感状态写入 sessionStorage(白名单序列化;无存储时为空操作)。 */
|
|
459
|
+
save(): void {
|
|
460
|
+
if (this.storage === null) return
|
|
461
|
+
try {
|
|
462
|
+
this.storage.setItem(STATE_KEY, JSON.stringify(toPersistedState(this.state)))
|
|
463
|
+
} catch {
|
|
464
|
+
// 存储满/不可用:静默降级,状态仍完整存在于内存
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* ---------------------------------------------------- 控制器实例(缓存) */
|
|
469
|
+
|
|
470
|
+
/** ExportFlow 控制器实例:懒创建 + 缓存(切 tab/关面板不重建)。 */
|
|
471
|
+
exportFlow(api: ConfigManagerApi): ExportFlow {
|
|
472
|
+
if (this.exportFlowInst === null) {
|
|
473
|
+
if (this.apiRef === null) this.apiRef = api
|
|
474
|
+
this.exportFlowInst = new ExportFlow({
|
|
475
|
+
port: api,
|
|
476
|
+
onProgress: (event) => { this.patch({ export: { progress: event } }) },
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
return this.exportFlowInst
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** ImportWizard 控制器实例:懒创建 + 缓存;首次创建时从持久化快照 rehydrate。 */
|
|
483
|
+
importWizard(api: ConfigManagerApi): ImportWizard {
|
|
484
|
+
if (this.importWizardInst === null) {
|
|
485
|
+
if (this.apiRef === null) this.apiRef = api
|
|
486
|
+
const wizard = new ImportWizard({
|
|
487
|
+
port: api,
|
|
488
|
+
onProgress: (event) => { this.patch({ import: { progress: event } }) },
|
|
489
|
+
defaultRollbackOnError: true,
|
|
490
|
+
})
|
|
491
|
+
// 刷新恢复:把持久化快照写回控制器(仅非敏感字段;secretInputs 置空)
|
|
492
|
+
writeWizardSnapshot(wizard, this.state.import)
|
|
493
|
+
this.importWizardInst = wizard
|
|
494
|
+
}
|
|
495
|
+
return this.importWizardInst
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/* ------------------------------------------------- m2-resume:run 重订阅 */
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 重新订阅进行中的 run:GET /runs 找回活跃 runId → 轮询 /progress 直到
|
|
502
|
+
* 完成/失败,把 RunState.result 回填 store(导出结果 / 导入结果均可恢复)。
|
|
503
|
+
* 返回是否有 run 被恢复。幂等:同一 runId 不会重复轮询。
|
|
504
|
+
*/
|
|
505
|
+
async resume(api: ConfigManagerApi): Promise<boolean> {
|
|
506
|
+
if (this.apiRef === null) this.apiRef = api
|
|
507
|
+
let active: RunState[]
|
|
508
|
+
try {
|
|
509
|
+
active = await api.runs()
|
|
510
|
+
} catch {
|
|
511
|
+
// host 不可达:保持现状,不做破坏性重置
|
|
512
|
+
return false
|
|
513
|
+
}
|
|
514
|
+
let resumed = false
|
|
515
|
+
const exportRun = active.find((r) => r.kind === 'export')
|
|
516
|
+
if (exportRun !== undefined) {
|
|
517
|
+
resumed = true
|
|
518
|
+
this.patch({
|
|
519
|
+
export: {
|
|
520
|
+
running: true,
|
|
521
|
+
runId: exportRun.runId,
|
|
522
|
+
progress: mapRunProgress('export', exportRun),
|
|
523
|
+
},
|
|
524
|
+
})
|
|
525
|
+
this.pollRun(exportRun.runId, 'export')
|
|
526
|
+
} else if (this.state.export.running) {
|
|
527
|
+
// 持久化显示导出中,但 host 无活跃导出 run:请求已结束且响应丢失,不可恢复
|
|
528
|
+
this.patch({
|
|
529
|
+
export: {
|
|
530
|
+
running: false,
|
|
531
|
+
progress: null,
|
|
532
|
+
runId: null,
|
|
533
|
+
error: '上次导出任务已结束但结果无法恢复,请重新导出',
|
|
534
|
+
},
|
|
535
|
+
})
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const importRun = active.find((r) => r.kind === 'import')
|
|
539
|
+
if (importRun !== undefined) {
|
|
540
|
+
resumed = true
|
|
541
|
+
this.patch({
|
|
542
|
+
import: {
|
|
543
|
+
running: true,
|
|
544
|
+
runId: importRun.runId,
|
|
545
|
+
step: 'importing',
|
|
546
|
+
progress: mapRunProgress('import', importRun),
|
|
547
|
+
},
|
|
548
|
+
})
|
|
549
|
+
this.pollRun(importRun.runId, 'import')
|
|
550
|
+
} else if (this.state.import.step === 'importing') {
|
|
551
|
+
// 持久化显示导入中,但 host 无活跃导入 run:任务已结束/被清理,结果不可恢复
|
|
552
|
+
this.importWizardInst?.reset()
|
|
553
|
+
this.patch({
|
|
554
|
+
import: {
|
|
555
|
+
step: 'select',
|
|
556
|
+
phase: 'preview',
|
|
557
|
+
runId: null,
|
|
558
|
+
running: false,
|
|
559
|
+
progress: null,
|
|
560
|
+
conflictCollector: null,
|
|
561
|
+
errors: [],
|
|
562
|
+
error: '上次导入任务已结束或超过保留期,结果无法恢复,请重新导入',
|
|
563
|
+
},
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
return resumed
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** 停止全部轮询(视图卸载时调用;服务端执行不受影响,重开面板再 resume)。 */
|
|
570
|
+
stopResume(): void {
|
|
571
|
+
this.polling.clear()
|
|
572
|
+
this.watchActive.clear()
|
|
573
|
+
for (const timer of this.timers.values()) clearTimeout(timer)
|
|
574
|
+
this.timers.clear()
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/* ------------------------------------- m3:本次会话内启动 run 的实时轮询 */
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* 本次会话内启动的 run(POST /export 或 /execute 请求进行期间)的实时进度订阅。
|
|
581
|
+
* 请求是同步的、响应到达才知 runId,所以先经 GET /runs 发现进行中的 run(500ms),
|
|
582
|
+
* 发现后转入 /progress 轮询(同一间隔),done/failed 自动停止。
|
|
583
|
+
*
|
|
584
|
+
* 与 m2 resume 的收敛:两者共用 pollRun / polling / timers 同一套轮询器,
|
|
585
|
+
* 只是间隔不同(resume 恢复 1s、本方法实时 500ms)且发现阶段不同
|
|
586
|
+
* (resume 用 /runs 一次性找回 runId;本方法持续 /runs 直到出现活跃 run)。
|
|
587
|
+
* 同一 runId 不会重复轮询(polling 集合防重)。
|
|
588
|
+
*/
|
|
589
|
+
watchRunning(kind: RunKind, intervalMs?: number): void {
|
|
590
|
+
if (this.watchActive.has(kind)) return
|
|
591
|
+
this.watchActive.add(kind)
|
|
592
|
+
const api = this.apiRef
|
|
593
|
+
if (api === null) {
|
|
594
|
+
this.watchActive.delete(kind)
|
|
595
|
+
return
|
|
596
|
+
}
|
|
597
|
+
const interval = intervalMs ?? this.pollIntervalMs
|
|
598
|
+
const tick = (): void => {
|
|
599
|
+
if (!this.watchActive.has(kind)) return
|
|
600
|
+
void api.runs().then(
|
|
601
|
+
(active) => {
|
|
602
|
+
if (!this.watchActive.has(kind)) return
|
|
603
|
+
const run = active.find((r) => r.kind === kind && r.status === 'running')
|
|
604
|
+
if (run === undefined) {
|
|
605
|
+
// 请求尚未注册 run / 已瞬间结束:继续发现,直到视图 stop 或转入 /progress
|
|
606
|
+
this.scheduleWatch(kind, tick, interval)
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
// 发现进行中 run:停止发现,交给 /progress 轮询器(同一间隔)
|
|
610
|
+
this.watchActive.delete(kind)
|
|
611
|
+
this.patchProgress(kind, run)
|
|
612
|
+
this.pollRun(run.runId, kind, interval)
|
|
613
|
+
},
|
|
614
|
+
() => {
|
|
615
|
+
if (this.watchActive.has(kind)) this.scheduleWatch(kind, tick, interval)
|
|
616
|
+
},
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
this.scheduleWatch(kind, tick, interval)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** 停止某 kind 的「发现进行中 run」轮询(视图请求结束后调用;/progress 轮询自行结束)。 */
|
|
623
|
+
stopRunWatch(kind: RunKind): void {
|
|
624
|
+
this.watchActive.delete(kind)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** 调度下一轮发现;每 kind 只保留一个挂起定时器。 */
|
|
628
|
+
private scheduleWatch(kind: RunKind, tick: () => void, intervalMs: number): void {
|
|
629
|
+
const timer = setTimeout(tick, intervalMs)
|
|
630
|
+
this.timers.set(`watch:${kind}`, timer)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private pollRun(runId: string, kind: RunKind, intervalMs?: number): void {
|
|
634
|
+
if (this.polling.has(runId)) return
|
|
635
|
+
this.polling.add(runId)
|
|
636
|
+
const api = this.apiRef
|
|
637
|
+
if (api === null) return
|
|
638
|
+
const interval = intervalMs ?? this.pollIntervalMs
|
|
639
|
+
const tick = (): void => {
|
|
640
|
+
if (!this.polling.has(runId)) return
|
|
641
|
+
void api.progress(runId).then(
|
|
642
|
+
(state) => {
|
|
643
|
+
if (!this.polling.has(runId)) return
|
|
644
|
+
if (state.status === 'running') {
|
|
645
|
+
this.patchProgress(kind, state)
|
|
646
|
+
this.scheduleTick(runId, tick, interval)
|
|
647
|
+
} else {
|
|
648
|
+
this.polling.delete(runId)
|
|
649
|
+
this.applySettled(kind, state)
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
() => {
|
|
653
|
+
// 404(过保留期)/网络错误:run 不可恢复,停止轮询并如实提示
|
|
654
|
+
this.polling.delete(runId)
|
|
655
|
+
this.applyGone(kind)
|
|
656
|
+
},
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
this.scheduleTick(runId, tick, interval)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** 调度下一轮询;每 runId 只保留一个挂起定时器(fire 后由下一次调度覆盖)。 */
|
|
663
|
+
private scheduleTick(runId: string, tick: () => void, intervalMs: number): void {
|
|
664
|
+
const timer = setTimeout(tick, intervalMs)
|
|
665
|
+
this.timers.set(runId, timer)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private patchProgress(kind: RunKind, state: RunState): void {
|
|
669
|
+
const event = mapRunProgress(kind, state)
|
|
670
|
+
if (kind === 'export') this.patch({ export: { progress: event } })
|
|
671
|
+
else this.patch({ import: { progress: event } })
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** run 完成/失败:把 RunState.result 回填 store(并镜像回控制器)。 */
|
|
675
|
+
private applySettled(kind: RunKind, state: RunState): void {
|
|
676
|
+
if (kind === 'export') {
|
|
677
|
+
const result = state.result as
|
|
678
|
+
| { zipPath: string; manifest: Manifest; report: ExportRunResult['report'] }
|
|
679
|
+
| undefined
|
|
680
|
+
if (state.status === 'done' && result !== undefined && typeof result.zipPath === 'string') {
|
|
681
|
+
this.patch({
|
|
682
|
+
export: {
|
|
683
|
+
running: false,
|
|
684
|
+
progress: { stage: 'done', step: 1, total: 1 },
|
|
685
|
+
result: { ...result, text: renderExportReport(result.report) },
|
|
686
|
+
downloaded: false,
|
|
687
|
+
},
|
|
688
|
+
})
|
|
689
|
+
} else {
|
|
690
|
+
this.patch({
|
|
691
|
+
export: {
|
|
692
|
+
running: false,
|
|
693
|
+
progress: null,
|
|
694
|
+
result: null,
|
|
695
|
+
downloaded: false,
|
|
696
|
+
error: state.error ?? '导出失败(结果不可用)',
|
|
697
|
+
},
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
// import
|
|
703
|
+
const result = state.result as ImportResult | undefined
|
|
704
|
+
if (state.status === 'done' && result !== undefined && typeof result === 'object') {
|
|
705
|
+
const wizard = this.importWizardInst
|
|
706
|
+
if (wizard !== null) {
|
|
707
|
+
const imp: ImportLiveState = {
|
|
708
|
+
...this.state.import,
|
|
709
|
+
step: 'result',
|
|
710
|
+
result,
|
|
711
|
+
secretInputs: {},
|
|
712
|
+
conflictCollector: null,
|
|
713
|
+
}
|
|
714
|
+
writeWizardSnapshot(wizard, imp)
|
|
715
|
+
}
|
|
716
|
+
this.patch({
|
|
717
|
+
import: {
|
|
718
|
+
step: 'result',
|
|
719
|
+
result,
|
|
720
|
+
running: false,
|
|
721
|
+
progress: { stage: 'done', step: 1, total: 1 },
|
|
722
|
+
error: null,
|
|
723
|
+
},
|
|
724
|
+
})
|
|
725
|
+
} else {
|
|
726
|
+
const wizard = this.importWizardInst
|
|
727
|
+
if (wizard !== null) {
|
|
728
|
+
const internals = wizard as unknown as WizardInternals
|
|
729
|
+
internals.errors = [...internals.errors, state.error ?? '导入失败']
|
|
730
|
+
}
|
|
731
|
+
this.patch({
|
|
732
|
+
import: {
|
|
733
|
+
running: false,
|
|
734
|
+
progress: null,
|
|
735
|
+
error: state.error ?? '导入失败',
|
|
736
|
+
errors: [...this.state.import.errors, state.error ?? '导入失败'],
|
|
737
|
+
},
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** run 消失(404/网络错误):停止轮询并提示不可恢复。 */
|
|
743
|
+
private applyGone(kind: RunKind): void {
|
|
744
|
+
if (kind === 'export') {
|
|
745
|
+
this.patch({
|
|
746
|
+
export: {
|
|
747
|
+
running: false,
|
|
748
|
+
progress: null,
|
|
749
|
+
runId: null,
|
|
750
|
+
error: '任务已结束或超过保留期,进度不可恢复',
|
|
751
|
+
},
|
|
752
|
+
})
|
|
753
|
+
} else {
|
|
754
|
+
this.importWizardInst?.reset()
|
|
755
|
+
this.patch({
|
|
756
|
+
import: {
|
|
757
|
+
running: false,
|
|
758
|
+
progress: null,
|
|
759
|
+
runId: null,
|
|
760
|
+
step: 'select',
|
|
761
|
+
phase: 'preview',
|
|
762
|
+
conflictCollector: null,
|
|
763
|
+
errors: [],
|
|
764
|
+
error: '任务已结束或超过保留期,进度不可恢复',
|
|
765
|
+
},
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** 模块级单例(浏览器半所有视图共享;构造时自动 load())。 */
|
|
772
|
+
export const runStore = new RunStore()
|