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/core/run-registry.ts
CHANGED
|
@@ -21,8 +21,8 @@ import { randomBytes } from 'node:crypto'
|
|
|
21
21
|
import { zhMsg } from './messages.ts'
|
|
22
22
|
import type { MsgFunc } from './messages.ts'
|
|
23
23
|
|
|
24
|
-
/** run 类型:导出 / 导入 / 自动同步 / 一键同步逐项应用 /
|
|
25
|
-
export type RunKind = 'export' | 'import' | 'autosync' | 'sync-apply' | 'backup-schedule'
|
|
24
|
+
/** run 类型:导出 / 导入 / 自动同步 / 一键同步逐项应用 / 定时备份 / 快照恢复。 */
|
|
25
|
+
export type RunKind = 'export' | 'import' | 'autosync' | 'sync-apply' | 'backup-schedule' | 'restore'
|
|
26
26
|
|
|
27
27
|
/** run 状态:进行中 / 完成 / 失败。 */
|
|
28
28
|
export type RunStatus = 'running' | 'done' | 'failed'
|
|
@@ -71,7 +71,9 @@ export class RunConflictError extends Error {
|
|
|
71
71
|
? '一键同步已在进行中'
|
|
72
72
|
: kind === 'backup-schedule'
|
|
73
73
|
? '定时备份已在进行中'
|
|
74
|
-
:
|
|
74
|
+
: kind === 'restore'
|
|
75
|
+
? '快照恢复已在进行中(请等待当前恢复完成)'
|
|
76
|
+
: msg('run.conflict.import', { runId }),
|
|
75
77
|
)
|
|
76
78
|
this.name = 'RunConflictError'
|
|
77
79
|
this.runId = runId
|
|
@@ -142,17 +144,22 @@ export class RunRegistry {
|
|
|
142
144
|
return { ...run }
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* 追加一行执行日志(导入逐计划项操作 / 子进程命令);run 不存在返回 undefined,
|
|
146
149
|
* 已完成/失败后的晚到追加被忽略(防御异步回调竞态)。行数封顶 MAX_RUN_LOG_LINES,
|
|
147
|
-
* 超限时截断保留最新行。
|
|
150
|
+
* 超限时截断保留最新行。
|
|
151
|
+
*
|
|
152
|
+
* **不可变写入(性能正确性关键)**:每次 append 生成新数组(push + splice 会原地
|
|
153
|
+
* mutation —— /progress 轮询返回的 RunState.log 与浏览器侧 store 共享同一引用,
|
|
154
|
+
* 行数封顶后长度恒定,React memo 按引用/长度比较将无法感知新行,日志面板冻结)。
|
|
155
|
+
* 调用方(mapRunProgress → ImportLogPanel)据此以「数组引用变化」判断有新输出。
|
|
156
|
+
*/
|
|
148
157
|
appendLog(runId: string, line: string): RunState | undefined {
|
|
149
158
|
const run = this.runs.get(runId)
|
|
150
159
|
if (run === undefined) return undefined
|
|
151
160
|
if (run.status !== 'running') return { ...run }
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
run.log.splice(0, run.log.length - MAX_RUN_LOG_LINES)
|
|
155
|
-
}
|
|
161
|
+
// 不可变追加:保留最新 MAX_RUN_LOG_LINES 行(slice(-(MAX-1)) 在长度不足时取全量)
|
|
162
|
+
run.log = [...run.log.slice(-(MAX_RUN_LOG_LINES - 1)), line]
|
|
156
163
|
run.updatedAt = this.now()
|
|
157
164
|
return { ...run }
|
|
158
165
|
}
|
package/src/index.ts
CHANGED
|
@@ -141,7 +141,7 @@ export const name = 'config-manager'
|
|
|
141
141
|
export const inject = ['settings', 'credentials']
|
|
142
142
|
|
|
143
143
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
144
|
-
const PLUGIN_VERSION = '0.1.
|
|
144
|
+
const PLUGIN_VERSION = '0.1.49'
|
|
145
145
|
|
|
146
146
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
147
147
|
const PLUGIN_NAME = 'dsh-config-manager'
|
|
@@ -1234,10 +1234,13 @@ export interface RestoreExecutor {
|
|
|
1234
1234
|
/**
|
|
1235
1235
|
* 按计划执行恢复动作(纯执行器;逐项 try/catch 不拖垮其余),
|
|
1236
1236
|
* 返回与 CLI 一致的诚实报告。顺序 = 计划顺序(整文件 → 插件 → file 补偿)。
|
|
1237
|
+
* @param onAction - 每项动作执行回调(宿主路由埋点:更新 RunRegistry 进度;
|
|
1238
|
+
* index/1-based、total=计划动作数、detail=动作描述)
|
|
1237
1239
|
*/
|
|
1238
1240
|
export async function executeRestorePlan(
|
|
1239
1241
|
plan: RestorePlan,
|
|
1240
1242
|
exec: RestoreExecutor,
|
|
1243
|
+
onAction?: (info: { index: number; total: number; detail: string }) => void,
|
|
1241
1244
|
): Promise<RestoreReport> {
|
|
1242
1245
|
const report: RestoreReport = {
|
|
1243
1246
|
snapshotId: plan.snapshotId,
|
|
@@ -1247,7 +1250,11 @@ export async function executeRestorePlan(
|
|
|
1247
1250
|
failed: [],
|
|
1248
1251
|
skipped: [],
|
|
1249
1252
|
}
|
|
1253
|
+
const total = plan.actions.length
|
|
1254
|
+
let index = 0
|
|
1250
1255
|
for (const action of plan.actions) {
|
|
1256
|
+
index += 1
|
|
1257
|
+
onAction?.({ index, total, detail: action.description })
|
|
1251
1258
|
try {
|
|
1252
1259
|
switch (action.kind) {
|
|
1253
1260
|
case 'hostFileRestore':
|
|
@@ -2096,6 +2103,13 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
|
|
|
2096
2103
|
// M4:快照恢复。dryRun=true 只返回动作计划(planRestore,零写入);
|
|
2097
2104
|
// 真实执行 = 计划 → 宿主执行器(ctx.fs 整文件/文件还原 + runDshPlugin 卸载插件)
|
|
2098
2105
|
// → 与 CLI 一致的诚实报告 { restored/removedPlugins/manualHints/failed/skipped }。
|
|
2106
|
+
//
|
|
2107
|
+
// **并发防护(P1-1)**:真实执行(dryRun=false)经 runs.register('restore') 登记——
|
|
2108
|
+
// 同 kind 已有 running 时抛 RunConflictError → 409 拒绝。这是宿主侧的权威防重
|
|
2109
|
+
// (前端 loading 只是 UX):即使两个 tab / 刷新后重复点击,同一时刻至多一个
|
|
2110
|
+
// restore 在执行(不同快照并发恢复会交错写文件,同快照并发会互相覆盖
|
|
2111
|
+
// pre-restore 双保险备份,都是真实数据风险)。进度经 onAction 埋点更新
|
|
2112
|
+
// RunRegistry(/progress 轮询 + /runs 刷新恢复可见);响应含 runId。
|
|
2099
2113
|
{
|
|
2100
2114
|
kind: 'exact',
|
|
2101
2115
|
path: API.restore,
|
|
@@ -2118,12 +2132,41 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
|
|
|
2118
2132
|
}
|
|
2119
2133
|
try {
|
|
2120
2134
|
if (dryRun) {
|
|
2135
|
+
// dry-run 零写入、只读探测:不登记 run(并发 dry-run 无害)
|
|
2121
2136
|
writeJson(res, 200, { dryRun: true, plan: await planRestore(restoreOpts) })
|
|
2122
2137
|
return
|
|
2123
2138
|
}
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2139
|
+
// 真实执行:先登记 run(同 kind running → 409 拒绝重复恢复)
|
|
2140
|
+
let run: RunState
|
|
2141
|
+
try {
|
|
2142
|
+
run = runs.register('restore')
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
writeJson(res, 409, { error: error instanceof Error ? error.message : String(error) })
|
|
2145
|
+
return
|
|
2146
|
+
}
|
|
2147
|
+
const runId = run.runId
|
|
2148
|
+
try {
|
|
2149
|
+
const plan = await planRestore(restoreOpts)
|
|
2150
|
+
const report = await executeRestorePlan(
|
|
2151
|
+
plan,
|
|
2152
|
+
makeRestoreExecutor(snapshotDir, host, host.profile),
|
|
2153
|
+
// m1 埋点:每执行一个恢复动作实时更新 run 状态(/progress 轮询可见)
|
|
2154
|
+
(info) => {
|
|
2155
|
+
runs.update(runId, {
|
|
2156
|
+
section: 'restore',
|
|
2157
|
+
item: info.index,
|
|
2158
|
+
itemTotal: info.total,
|
|
2159
|
+
detail: info.detail,
|
|
2160
|
+
})
|
|
2161
|
+
},
|
|
2162
|
+
)
|
|
2163
|
+
runs.finish(runId, report)
|
|
2164
|
+
writeJson(res, 200, { dryRun: false, report, runId })
|
|
2165
|
+
} catch (error) {
|
|
2166
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
2167
|
+
runs.fail(runId, message)
|
|
2168
|
+
writeJson(res, 400, { error: message, runId })
|
|
2169
|
+
}
|
|
2127
2170
|
} catch (error) {
|
|
2128
2171
|
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
2129
2172
|
}
|
|
@@ -2132,11 +2175,14 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
|
|
|
2132
2175
|
// -------------------------------------------------- backup-schedule
|
|
2133
2176
|
// 定时全量备份设置(GET 读 / PUT 存 sync/backup-schedule.json;无敏感字段):
|
|
2134
2177
|
// 保存后重排调度器(reload);恒不含 secret、不加密(与自动同步同语义)。
|
|
2178
|
+
// 与全仓一致:每个方法分支都过 loopback fence(guard)——其他 /api/dsh-config-manager/*
|
|
2179
|
+
// 路由全部首行 guard,新增路由不得遗漏(安全不变量:仅 loopback + 同源可访问)。
|
|
2135
2180
|
{
|
|
2136
2181
|
kind: 'exact',
|
|
2137
2182
|
path: API.backupSchedule,
|
|
2138
2183
|
handler: async (req, res) => {
|
|
2139
2184
|
if (req.method === 'GET') {
|
|
2185
|
+
if (!guard(req, res, 'GET')) return
|
|
2140
2186
|
try {
|
|
2141
2187
|
writeJson(res, 200, { schedule: await readBackupSchedule(syncDir) })
|
|
2142
2188
|
} catch (error) {
|
|
@@ -2145,6 +2191,7 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
|
|
|
2145
2191
|
return
|
|
2146
2192
|
}
|
|
2147
2193
|
if (req.method === 'PUT') {
|
|
2194
|
+
if (!guard(req, res, 'PUT')) return
|
|
2148
2195
|
try {
|
|
2149
2196
|
const body = await readJsonBody(req)
|
|
2150
2197
|
const parsed = validateBackupScheduleDraft(body)
|
|
@@ -2168,14 +2215,12 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
|
|
|
2168
2215
|
// ------------------------------------------------- backup-schedule/run
|
|
2169
2216
|
// 立即执行一次全量备份(复用 BackupScheduler.runOnce,同一时刻防重):
|
|
2170
2217
|
// 返回执行结果(status/zip/skipReason/error)+ 最新配置(含 lastRun 状态)。
|
|
2218
|
+
// 同全仓:loopback fence(guard)——远程调用方不得触发宿主写盘操作。
|
|
2171
2219
|
{
|
|
2172
2220
|
kind: 'exact',
|
|
2173
2221
|
path: API.backupScheduleRun,
|
|
2174
2222
|
handler: async (req, res) => {
|
|
2175
|
-
if (req
|
|
2176
|
-
writeJson(res, 405, { error: `method ${req.method} not allowed` })
|
|
2177
|
-
return
|
|
2178
|
-
}
|
|
2223
|
+
if (!guard(req, res, 'POST')) return
|
|
2179
2224
|
try {
|
|
2180
2225
|
const run = await backupScheduler.runOnce()
|
|
2181
2226
|
const schedule = await readBackupSchedule(syncDir)
|