@summerscar/dsh-mnemon-sync 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +86 -2
- package/client.js +1194 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +15 -0
- package/index.js +731 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +75 -4
- package/scheduler.js +241 -0
- package/snapshot.js +400 -0
- package/source.js +201 -0
- package/store.js +187 -0
- package/targets.js +533 -0
- package/zip.js +168 -0
package/client.js
ADDED
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mnemon Sync — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Registers one page into dsh-mnemon's Memory Spaces workbench as an extra tab.
|
|
5
|
+
*
|
|
6
|
+
* How the tab gets there: dsh-mnemon exposes the child slot
|
|
7
|
+
* `mnemon.source.page`, and its page directory groups entries by the id
|
|
8
|
+
* prefix before the slash (`<sourceTypeId>/<pageId>`). The directory filters
|
|
9
|
+
* by which source *type* is currently running — it does not care which plugin
|
|
10
|
+
* contributed the page. Registering as `memory-spaces/sync` therefore lands a
|
|
11
|
+
* tab beside the store's own pages, with no patch to dsh-mnemon itself.
|
|
12
|
+
*
|
|
13
|
+
* Everything the page shows comes from `/api/mnemon-sync/*`; the browser never
|
|
14
|
+
* sees a secret, because the Host masks it before sending and accepts the mask
|
|
15
|
+
* back as "unchanged".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: '@summerscar/dsh-mnemon-sync',
|
|
20
|
+
factory(require) {
|
|
21
|
+
const React = require('react')
|
|
22
|
+
const h = React.createElement
|
|
23
|
+
|
|
24
|
+
const NS = 'mnemon-sync'
|
|
25
|
+
const API = '/api/mnemon-sync'
|
|
26
|
+
// Its own type, not "memory-spaces". The workbench marks a tab active when
|
|
27
|
+
// the open page shares its source type, so borrowing another type would
|
|
28
|
+
// highlight two tabs at once. This type is backed by the minimal Source in
|
|
29
|
+
// source.js, which is what makes the catalog list it in the first place.
|
|
30
|
+
const SOURCE_TYPE_ID = 'mnemon-sync'
|
|
31
|
+
const PAGE_ID = 'sync'
|
|
32
|
+
|
|
33
|
+
// The three components dsh-mnemon groups its own Pack by. Order matches the
|
|
34
|
+
// order its panel lists them, so the page reads the same way.
|
|
35
|
+
const COMPONENT_ROWS = [
|
|
36
|
+
{ id: 'memory-spaces', label: 'componentMemorySpaces' },
|
|
37
|
+
{ id: 'documents', label: 'componentDocuments' },
|
|
38
|
+
{ id: 'runtime', label: 'componentRuntime' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const zh = {
|
|
42
|
+
tabLabel: '同步备份',
|
|
43
|
+
title: '记忆空间同步备份',
|
|
44
|
+
subtitle: '把 Mnemon 的记忆组件打包上传到 S3 或 WebDAV',
|
|
45
|
+
config: '备份配置',
|
|
46
|
+
configNote: '以下三项一起保存。',
|
|
47
|
+
scope: '备份范围',
|
|
48
|
+
scopeNote: '选择要备份的组件,可多选。只有被选中的内容会进入备份包。',
|
|
49
|
+
scopePath: 'Mnemon 数据根目录',
|
|
50
|
+
componentMemorySpaces: '记忆空间',
|
|
51
|
+
componentDocuments: '项目档案',
|
|
52
|
+
componentRuntime: '运行时记忆',
|
|
53
|
+
componentEmpty: '暂无数据',
|
|
54
|
+
componentFiles: '{count} 个文件',
|
|
55
|
+
componentSpaceOne: '1 个空间',
|
|
56
|
+
componentSpaces: '{count} 个空间',
|
|
57
|
+
backups: '备份列表',
|
|
58
|
+
listBackups: '从目标列出备份',
|
|
59
|
+
refresh: '刷新',
|
|
60
|
+
backupsHint: '列出远端已存在的备份包,可查看明细并恢复。',
|
|
61
|
+
noBackups: '目标上还没有备份包',
|
|
62
|
+
backupName: '文件名',
|
|
63
|
+
backupTime: '创建时间',
|
|
64
|
+
details: '明细',
|
|
65
|
+
contains: '包含组件',
|
|
66
|
+
files: '文件数',
|
|
67
|
+
intact: '校验通过',
|
|
68
|
+
damaged: '校验失败',
|
|
69
|
+
missingWarn: '缺少组件:',
|
|
70
|
+
planHint: '将写入 {count} 个文件({size})。',
|
|
71
|
+
restorePick: '选择要恢复的组件',
|
|
72
|
+
restorePickOne: '请至少选择一个组件',
|
|
73
|
+
restoreSpacesNote: '{count} 个文件({size}),将覆盖本地记忆空间数据库',
|
|
74
|
+
restoreDocumentsNote: '{count} 个文件({size}),将覆盖项目档案',
|
|
75
|
+
restoreRuntimeNote: '{count} 个文件({size}),将覆盖运行时记忆',
|
|
76
|
+
restoreDryRun: '预览恢复',
|
|
77
|
+
restoreApply: '执行恢复',
|
|
78
|
+
restoring: '恢复中…',
|
|
79
|
+
restoreDone: '已恢复 {count} 个文件({size})',
|
|
80
|
+
restorePlan: '将写入 {count} 个文件({size}),未做改动',
|
|
81
|
+
restoreFailed: '恢复失败',
|
|
82
|
+
missingBlocksRestore: '备份包缺少所选组件,已阻止恢复',
|
|
83
|
+
spaces: '记忆空间',
|
|
84
|
+
noSpaces: '还没有任何记忆空间',
|
|
85
|
+
totalBytes: '数据体积',
|
|
86
|
+
target: '备份目标',
|
|
87
|
+
kind: '类型',
|
|
88
|
+
webdavUrl: 'WebDAV 地址',
|
|
89
|
+
webdavUser: '用户名',
|
|
90
|
+
webdavPassword: '密码',
|
|
91
|
+
s3Endpoint: 'Endpoint',
|
|
92
|
+
s3Bucket: 'Bucket',
|
|
93
|
+
s3Region: 'Region',
|
|
94
|
+
s3AccessKey: 'Access Key ID',
|
|
95
|
+
s3SecretKey: 'Secret Access Key',
|
|
96
|
+
s3SessionToken: 'Session Token(可选)',
|
|
97
|
+
forcePathStyle: '强制 path-style(MinIO / R2 常用)',
|
|
98
|
+
remotePath: '远端目录',
|
|
99
|
+
runAt: '时间',
|
|
100
|
+
schedule: '自动备份',
|
|
101
|
+
autoOff: '关闭',
|
|
102
|
+
autoHour: '每小时',
|
|
103
|
+
autoHours: '每 {hours} 小时',
|
|
104
|
+
autoDay: '每天',
|
|
105
|
+
autoDays: '每 {days} 天',
|
|
106
|
+
autoHintOff: '只在你点“立即备份”时备份',
|
|
107
|
+
autoHintOn: '按间隔在后台备份,和“立即备份”做的事一样',
|
|
108
|
+
autoNext: '下次 {time}',
|
|
109
|
+
autoLast: '上次 {time}',
|
|
110
|
+
autoFailed: '上次自动备份失败:{error}',
|
|
111
|
+
retention: '保留份数',
|
|
112
|
+
retentionNote: '超出后删除最旧的备份包。',
|
|
113
|
+
enabled: '启用自动备份',
|
|
114
|
+
save: '保存设置',
|
|
115
|
+
saving: '保存中…',
|
|
116
|
+
saved: '已保存',
|
|
117
|
+
saveFailed: '保存失败',
|
|
118
|
+
backupNow: '立即备份',
|
|
119
|
+
backingUp: '备份中…',
|
|
120
|
+
backupDone: '备份完成',
|
|
121
|
+
backupFailed: '备份失败',
|
|
122
|
+
history: '运行记录',
|
|
123
|
+
noHistory: '还没有运行记录',
|
|
124
|
+
ok: '成功',
|
|
125
|
+
failed: '失败',
|
|
126
|
+
trigger: '触发方式',
|
|
127
|
+
triggerManual: '手动',
|
|
128
|
+
triggerSchedule: '定时',
|
|
129
|
+
duration: '耗时',
|
|
130
|
+
size: '体积',
|
|
131
|
+
componentsCount: '组件',
|
|
132
|
+
clearHistory: '清空记录',
|
|
133
|
+
historyCleared: '运行记录已清空',
|
|
134
|
+
clearFailed: '清空失败',
|
|
135
|
+
error: '错误',
|
|
136
|
+
loading: '读取中…',
|
|
137
|
+
loadFailed: '读取失败',
|
|
138
|
+
retry: '重试',
|
|
139
|
+
credentials: '凭据',
|
|
140
|
+
credentialsNote: '凭据保存在记忆目录下的 0600 文件里,不会写进 DSH 配置,也不会被打包进备份。',
|
|
141
|
+
maskedHint: '已保存的密钥只会显示为掩码,留空或保持掩码表示不修改。',
|
|
142
|
+
danger: '注意',
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const en = {
|
|
146
|
+
tabLabel: 'Sync backup',
|
|
147
|
+
title: 'Memory space sync backup',
|
|
148
|
+
subtitle: 'Pack Mnemon\u2019s memory components to S3 or WebDAV',
|
|
149
|
+
config: 'Backup settings',
|
|
150
|
+
configNote: 'All three sections below save together.',
|
|
151
|
+
scope: 'Backup scope',
|
|
152
|
+
scopeNote: 'Choose which components to back up; only the selected ones enter the archive.',
|
|
153
|
+
scopePath: 'Mnemon data root',
|
|
154
|
+
componentMemorySpaces: 'Memory spaces',
|
|
155
|
+
componentDocuments: 'Project documents',
|
|
156
|
+
componentRuntime: 'Runtime memory',
|
|
157
|
+
componentEmpty: 'no data',
|
|
158
|
+
componentFiles: '{count} files',
|
|
159
|
+
componentSpaceOne: '1 space',
|
|
160
|
+
componentSpaces: '{count} spaces',
|
|
161
|
+
backups: 'Backups',
|
|
162
|
+
listBackups: 'List from target',
|
|
163
|
+
refresh: 'Refresh',
|
|
164
|
+
backupsHint: 'List the archives already on the target, inspect one, and restore it.',
|
|
165
|
+
noBackups: 'No archive on the target yet',
|
|
166
|
+
backupName: 'File',
|
|
167
|
+
backupTime: 'Created',
|
|
168
|
+
details: 'Details',
|
|
169
|
+
contains: 'Components',
|
|
170
|
+
files: 'Files',
|
|
171
|
+
intact: 'Verified',
|
|
172
|
+
damaged: 'Damaged',
|
|
173
|
+
missingWarn: 'Missing components:',
|
|
174
|
+
planHint: 'Would write {count} files ({size}).',
|
|
175
|
+
restorePick: 'Components to restore',
|
|
176
|
+
restorePickOne: 'Select at least one component',
|
|
177
|
+
restoreSpacesNote: '{count} files ({size}); overwrites local memory space databases',
|
|
178
|
+
restoreDocumentsNote: '{count} files ({size}); overwrites project documents',
|
|
179
|
+
restoreRuntimeNote: '{count} files ({size}); overwrites runtime memory',
|
|
180
|
+
restoreDryRun: 'Preview restore',
|
|
181
|
+
restoreApply: 'Restore now',
|
|
182
|
+
restoring: 'Restoring2026',
|
|
183
|
+
restoreDone: 'Restored {count} files ({size})',
|
|
184
|
+
restorePlan: 'Would write {count} files ({size}); nothing changed',
|
|
185
|
+
restoreFailed: 'Restore failed',
|
|
186
|
+
missingBlocksRestore: 'The archive lacks the selected components, so the restore was blocked',
|
|
187
|
+
spaces: 'Memory spaces',
|
|
188
|
+
noSpaces: 'No memory spaces yet',
|
|
189
|
+
totalBytes: 'Data size',
|
|
190
|
+
target: 'Target',
|
|
191
|
+
kind: 'Type',
|
|
192
|
+
webdavUrl: 'WebDAV URL',
|
|
193
|
+
webdavUser: 'Username',
|
|
194
|
+
webdavPassword: 'Password',
|
|
195
|
+
s3Endpoint: 'Endpoint',
|
|
196
|
+
s3Bucket: 'Bucket',
|
|
197
|
+
s3Region: 'Region',
|
|
198
|
+
s3AccessKey: 'Access Key ID',
|
|
199
|
+
s3SecretKey: 'Secret Access Key',
|
|
200
|
+
s3SessionToken: 'Session token (optional)',
|
|
201
|
+
forcePathStyle: 'Force path-style (MinIO / R2)',
|
|
202
|
+
remotePath: 'Remote directory',
|
|
203
|
+
runAt: 'When',
|
|
204
|
+
schedule: 'Automatic backup',
|
|
205
|
+
autoOff: 'Off',
|
|
206
|
+
autoHour: 'Hourly',
|
|
207
|
+
autoHours: 'Every {hours} hours',
|
|
208
|
+
autoDay: 'Daily',
|
|
209
|
+
autoDays: 'Every {days} days',
|
|
210
|
+
autoHintOff: 'Only when you press Backup now',
|
|
211
|
+
autoHintOn: 'Backs up in the background on this interval, the same work as Backup now',
|
|
212
|
+
autoNext: 'Next {time}',
|
|
213
|
+
autoLast: 'Last {time}',
|
|
214
|
+
autoFailed: 'The last automatic backup failed: {error}',
|
|
215
|
+
retention: 'Keep',
|
|
216
|
+
retentionNote: 'Older archives are deleted beyond this count.',
|
|
217
|
+
enabled: 'Enable automatic backup',
|
|
218
|
+
save: 'Save settings',
|
|
219
|
+
saving: 'Saving\u2026',
|
|
220
|
+
saved: 'Saved',
|
|
221
|
+
saveFailed: 'Save failed',
|
|
222
|
+
backupNow: 'Back up now',
|
|
223
|
+
backingUp: 'Backing up\u2026',
|
|
224
|
+
backupDone: 'Backup complete',
|
|
225
|
+
backupFailed: 'Backup failed',
|
|
226
|
+
history: 'Run history',
|
|
227
|
+
noHistory: 'No runs yet',
|
|
228
|
+
ok: 'OK',
|
|
229
|
+
failed: 'failed',
|
|
230
|
+
trigger: 'Trigger',
|
|
231
|
+
triggerManual: 'manual',
|
|
232
|
+
triggerSchedule: 'schedule',
|
|
233
|
+
duration: 'Duration',
|
|
234
|
+
size: 'Size',
|
|
235
|
+
componentsCount: 'Components',
|
|
236
|
+
clearHistory: 'Clear log',
|
|
237
|
+
historyCleared: 'Run history cleared',
|
|
238
|
+
clearFailed: 'Could not clear',
|
|
239
|
+
error: 'Error',
|
|
240
|
+
loading: 'Loading\u2026',
|
|
241
|
+
loadFailed: 'Could not load',
|
|
242
|
+
retry: 'Retry',
|
|
243
|
+
credentials: 'Credentials',
|
|
244
|
+
credentialsNote: 'Stored in a 0600 file inside the memory directory \u2014 never in DSH config, never inside a backup.',
|
|
245
|
+
maskedHint: 'A saved secret shows as a mask; leave it or keep the mask to keep it unchanged.',
|
|
246
|
+
danger: 'Careful',
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------
|
|
250
|
+
// Helpers
|
|
251
|
+
// ---------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The one-line summary under a component's name.
|
|
255
|
+
*
|
|
256
|
+
* Memory spaces are counted rather than listed: a home with many spaces
|
|
257
|
+
* would otherwise push the component's own name out of the row. Files are
|
|
258
|
+
* counted the same way, so both rows read as "how much is here".
|
|
259
|
+
*/
|
|
260
|
+
function componentDetail(t, id, part) {
|
|
261
|
+
if (id === 'memory-spaces') {
|
|
262
|
+
const spaces = part.spaces || []
|
|
263
|
+
if (spaces.length === 0) return t('componentEmpty')
|
|
264
|
+
return spaceCountLabel(t, spaces.length)
|
|
265
|
+
}
|
|
266
|
+
const files = part.files || []
|
|
267
|
+
if (files.length === 0) return t('componentEmpty')
|
|
268
|
+
return t('componentFiles', { count: files.length })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The same summary with every name spelled out, shown on hover. */
|
|
272
|
+
function componentTitle(id, part, spaceNames) {
|
|
273
|
+
if (id !== 'memory-spaces') return undefined
|
|
274
|
+
const spaces = part.spaces || []
|
|
275
|
+
if (spaces.length === 0) return undefined
|
|
276
|
+
return spaces
|
|
277
|
+
.map((space) => (spaceNames && spaceNames[space.id]) || space.id)
|
|
278
|
+
.join('\n')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* One line describing what restoring a component would write.
|
|
283
|
+
*
|
|
284
|
+
* Naming the concrete target keeps the picker honest: "3 files" does not
|
|
285
|
+
* tell anyone that the live databases will be overwritten, and that is the
|
|
286
|
+
* one consequence worth stating before the button is pressed.
|
|
287
|
+
*/
|
|
288
|
+
function describeRestoreTarget(component, inspection, t) {
|
|
289
|
+
const plan = inspection.plan || []
|
|
290
|
+
const count = plan.filter((entry) => entry.component === component).length
|
|
291
|
+
const size = humanBytes(plan
|
|
292
|
+
.filter((entry) => entry.component === component)
|
|
293
|
+
.reduce((total, entry) => total + (entry.bytes || 0), 0))
|
|
294
|
+
if (component === 'memory-spaces') return t('restoreSpacesNote', { count, size })
|
|
295
|
+
if (component === 'documents') return t('restoreDocumentsNote', { count, size })
|
|
296
|
+
if (component === 'runtime') return t('restoreRuntimeNote', { count, size })
|
|
297
|
+
return t('componentFiles', { count })
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** "1 space" reads differently from "3 spaces" in both languages offered. */
|
|
301
|
+
function spaceCountLabel(t, count) {
|
|
302
|
+
return count === 1 ? t('componentSpaceOne') : t('componentSpaces', { count })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** One component's display name, shared by the scope rows and the details. */
|
|
306
|
+
function componentLabel(t, component) {
|
|
307
|
+
if (component === 'memory-spaces') return t('componentMemorySpaces')
|
|
308
|
+
if (component === 'documents') return t('componentDocuments')
|
|
309
|
+
if (component === 'runtime') return t('componentRuntime')
|
|
310
|
+
return component
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function humanBytes(bytes) {
|
|
314
|
+
const value = Number(bytes)
|
|
315
|
+
if (!Number.isFinite(value) || value <= 0) return '0 B'
|
|
316
|
+
const units = ['B', 'KB', 'MB', 'GB']
|
|
317
|
+
let size = value
|
|
318
|
+
let index = 0
|
|
319
|
+
while (size >= 1024 && index < units.length - 1) {
|
|
320
|
+
size /= 1024
|
|
321
|
+
index += 1
|
|
322
|
+
}
|
|
323
|
+
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function stamp(value) {
|
|
327
|
+
if (typeof value !== 'string' || value === '') return ''
|
|
328
|
+
const date = new Date(value)
|
|
329
|
+
if (Number.isNaN(date.getTime())) return value
|
|
330
|
+
return date.toLocaleString()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function api(path, options) {
|
|
334
|
+
const response = await fetch(API + path, {
|
|
335
|
+
...options,
|
|
336
|
+
headers: { 'content-type': 'application/json', ...(options && options.headers) },
|
|
337
|
+
})
|
|
338
|
+
const text = await response.text()
|
|
339
|
+
let body
|
|
340
|
+
try {
|
|
341
|
+
body = text === '' ? {} : JSON.parse(text)
|
|
342
|
+
} catch {
|
|
343
|
+
body = { error: text }
|
|
344
|
+
}
|
|
345
|
+
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`)
|
|
346
|
+
return body
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------
|
|
350
|
+
// Styles (plain objects: the page inherits the workbench theme)
|
|
351
|
+
// ---------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
const styles = {
|
|
354
|
+
page: { display: 'grid', gap: 16, padding: '4px 2px 28px', minWidth: 0 },
|
|
355
|
+
header: { display: 'grid', gap: 4 },
|
|
356
|
+
title: { margin: 0, fontSize: 16, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' },
|
|
357
|
+
subtitle: { margin: 0, fontSize: 12.5, lineHeight: '19px', color: 'var(--dsw-alias-label-tertiary)' },
|
|
358
|
+
card: {
|
|
359
|
+
border: '1px solid var(--dsw-alias-separator-secondary)',
|
|
360
|
+
borderRadius: 10,
|
|
361
|
+
background: 'var(--dsw-alias-bg-module-platform)',
|
|
362
|
+
padding: '14px 16px',
|
|
363
|
+
display: 'grid',
|
|
364
|
+
gap: 12,
|
|
365
|
+
minWidth: 0,
|
|
366
|
+
},
|
|
367
|
+
cardTitle: { margin: 0, fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)' },
|
|
368
|
+
// A section inside a card: lighter than the card title, separated by a
|
|
369
|
+
// hairline so the three configuration groups read as parts of one decision.
|
|
370
|
+
group: {
|
|
371
|
+
display: 'grid',
|
|
372
|
+
gap: 10,
|
|
373
|
+
paddingTop: 14,
|
|
374
|
+
borderTop: '1px solid var(--dsw-alias-separator-secondary)',
|
|
375
|
+
},
|
|
376
|
+
groupTitle: {
|
|
377
|
+
margin: 0,
|
|
378
|
+
fontSize: 12.5,
|
|
379
|
+
fontWeight: 600,
|
|
380
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
381
|
+
},
|
|
382
|
+
// The save bar closes the card: it commits the scope, the target and the
|
|
383
|
+
// cadence together, so it sits under all three rather than under one.
|
|
384
|
+
saveBar: {
|
|
385
|
+
display: 'flex',
|
|
386
|
+
gap: 10,
|
|
387
|
+
alignItems: 'center',
|
|
388
|
+
paddingTop: 14,
|
|
389
|
+
borderTop: '1px solid var(--dsw-alias-separator-secondary)',
|
|
390
|
+
},
|
|
391
|
+
note: { margin: 0, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)' },
|
|
392
|
+
row: { display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', minWidth: 0 },
|
|
393
|
+
field: { display: 'grid', gap: 5, minWidth: 0, flex: '1 1 220px' },
|
|
394
|
+
label: { fontSize: 12, fontWeight: 500, color: 'var(--dsw-alias-label-secondary)' },
|
|
395
|
+
input: {
|
|
396
|
+
height: 32,
|
|
397
|
+
padding: '0 10px',
|
|
398
|
+
borderRadius: 7,
|
|
399
|
+
border: '1px solid var(--dsw-alias-separator-secondary)',
|
|
400
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
401
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
402
|
+
font: 'inherit',
|
|
403
|
+
fontSize: 13,
|
|
404
|
+
minWidth: 0,
|
|
405
|
+
},
|
|
406
|
+
select: {
|
|
407
|
+
height: 32,
|
|
408
|
+
padding: '0 8px',
|
|
409
|
+
borderRadius: 7,
|
|
410
|
+
border: '1px solid var(--dsw-alias-separator-secondary)',
|
|
411
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
412
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
413
|
+
font: 'inherit',
|
|
414
|
+
fontSize: 13,
|
|
415
|
+
},
|
|
416
|
+
button: {
|
|
417
|
+
height: 32,
|
|
418
|
+
padding: '0 14px',
|
|
419
|
+
borderRadius: 7,
|
|
420
|
+
border: '1px solid var(--dsw-alias-separator-secondary)',
|
|
421
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
422
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
423
|
+
font: 'inherit',
|
|
424
|
+
fontSize: 13,
|
|
425
|
+
cursor: 'pointer',
|
|
426
|
+
},
|
|
427
|
+
primary: {
|
|
428
|
+
height: 32,
|
|
429
|
+
padding: '0 16px',
|
|
430
|
+
borderRadius: 7,
|
|
431
|
+
border: '1px solid transparent',
|
|
432
|
+
background: 'var(--dsw-alias-state-business-primary)',
|
|
433
|
+
color: '#fff',
|
|
434
|
+
font: 'inherit',
|
|
435
|
+
fontSize: 13,
|
|
436
|
+
fontWeight: 500,
|
|
437
|
+
cursor: 'pointer',
|
|
438
|
+
},
|
|
439
|
+
kv: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '6px 14px', fontSize: 12.5, alignItems: 'baseline' },
|
|
440
|
+
kvKey: { color: 'var(--dsw-alias-label-tertiary)' },
|
|
441
|
+
kvValue: { color: 'var(--dsw-alias-label-primary)', overflowWrap: 'anywhere' },
|
|
442
|
+
pill: {
|
|
443
|
+
display: 'inline-flex',
|
|
444
|
+
alignItems: 'center',
|
|
445
|
+
gap: 5,
|
|
446
|
+
padding: '1px 8px',
|
|
447
|
+
borderRadius: 20,
|
|
448
|
+
fontSize: 11.5,
|
|
449
|
+
border: '1px solid var(--dsw-alias-separator-secondary)',
|
|
450
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
451
|
+
whiteSpace: 'nowrap',
|
|
452
|
+
},
|
|
453
|
+
pillOk: { color: 'var(--dsw-alias-state-success-primary, #3aa76d)', borderColor: 'currentColor' },
|
|
454
|
+
pillBad: { color: 'var(--dsw-alias-state-error-primary, #d64545)', borderColor: 'currentColor' },
|
|
455
|
+
table: { width: '100%', borderCollapse: 'collapse', fontSize: 12.5 },
|
|
456
|
+
th: {
|
|
457
|
+
textAlign: 'left',
|
|
458
|
+
fontWeight: 500,
|
|
459
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
460
|
+
padding: '6px 8px',
|
|
461
|
+
borderBottom: '1px solid var(--dsw-alias-separator-secondary)',
|
|
462
|
+
whiteSpace: 'nowrap',
|
|
463
|
+
},
|
|
464
|
+
td: {
|
|
465
|
+
padding: '7px 8px',
|
|
466
|
+
borderBottom: '1px solid var(--dsw-alias-separator-secondary)',
|
|
467
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
468
|
+
verticalAlign: 'top',
|
|
469
|
+
overflowWrap: 'anywhere',
|
|
470
|
+
},
|
|
471
|
+
banner: {
|
|
472
|
+
borderRadius: 8,
|
|
473
|
+
padding: '9px 12px',
|
|
474
|
+
fontSize: 12.5,
|
|
475
|
+
lineHeight: '19px',
|
|
476
|
+
border: '1px solid var(--dsw-alias-separator-secondary)',
|
|
477
|
+
overflowWrap: 'anywhere',
|
|
478
|
+
},
|
|
479
|
+
bannerOk: { borderColor: 'var(--dsw-alias-state-success-primary, #3aa76d)', color: 'var(--dsw-alias-state-success-primary, #3aa76d)' },
|
|
480
|
+
bannerBad: { borderColor: 'var(--dsw-alias-state-error-primary, #d64545)', color: 'var(--dsw-alias-state-error-primary, #d64545)' },
|
|
481
|
+
bannerWarn: {
|
|
482
|
+
borderColor: 'var(--dsw-alias-state-warn-primary, #c08a2e)',
|
|
483
|
+
color: 'var(--dsw-alias-state-warn-primary, #c08a2e)',
|
|
484
|
+
},
|
|
485
|
+
selectedRow: { background: 'var(--dsw-alias-bg-module-platform)' },
|
|
486
|
+
detail: {
|
|
487
|
+
marginTop: 12,
|
|
488
|
+
paddingTop: 12,
|
|
489
|
+
borderTop: '1px solid var(--dsw-alias-separator-secondary)',
|
|
490
|
+
display: 'grid',
|
|
491
|
+
gap: 10,
|
|
492
|
+
},
|
|
493
|
+
detailTitle: { fontSize: 12.5, fontWeight: 600, color: 'var(--dsw-alias-label-primary)', overflowWrap: 'anywhere' },
|
|
494
|
+
restorePicker: { display: 'grid', gap: 6 },
|
|
495
|
+
pickerTitle: { fontSize: 12, fontWeight: 500, color: 'var(--dsw-alias-label-secondary)' },
|
|
496
|
+
// Restore overwrites live data, so its button is visually distinct from
|
|
497
|
+
// the ordinary secondary actions rather than one shade of grey apart.
|
|
498
|
+
dangerButton: {
|
|
499
|
+
height: 32,
|
|
500
|
+
padding: '0 16px',
|
|
501
|
+
borderRadius: 7,
|
|
502
|
+
border: '1px solid var(--dsw-alias-state-error-primary, #d64545)',
|
|
503
|
+
background: 'transparent',
|
|
504
|
+
color: 'var(--dsw-alias-state-error-primary, #d64545)',
|
|
505
|
+
font: 'inherit',
|
|
506
|
+
fontSize: 13,
|
|
507
|
+
fontWeight: 500,
|
|
508
|
+
cursor: 'pointer',
|
|
509
|
+
},
|
|
510
|
+
checkboxRow: { display: 'flex', gap: 8, alignItems: 'center', fontSize: 12.5, color: 'var(--dsw-alias-label-secondary)' },
|
|
511
|
+
componentList: { display: 'grid', gap: 6 },
|
|
512
|
+
componentRow: {
|
|
513
|
+
display: 'grid',
|
|
514
|
+
gridTemplateColumns: 'auto minmax(0, 1fr) auto',
|
|
515
|
+
alignItems: 'center',
|
|
516
|
+
gap: 10,
|
|
517
|
+
padding: '8px 10px',
|
|
518
|
+
borderRadius: 8,
|
|
519
|
+
border: '1px solid var(--dsw-alias-separator-secondary)',
|
|
520
|
+
cursor: 'pointer',
|
|
521
|
+
},
|
|
522
|
+
componentText: { display: 'grid', gap: 1, minWidth: 0 },
|
|
523
|
+
componentName: { fontSize: 13, fontWeight: 500, color: 'var(--dsw-alias-label-primary)' },
|
|
524
|
+
componentDetail: {
|
|
525
|
+
fontSize: 11.5,
|
|
526
|
+
lineHeight: '17px',
|
|
527
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
528
|
+
overflow: 'hidden',
|
|
529
|
+
textOverflow: 'ellipsis',
|
|
530
|
+
whiteSpace: 'nowrap',
|
|
531
|
+
},
|
|
532
|
+
componentSize: {
|
|
533
|
+
fontSize: 11.5,
|
|
534
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
535
|
+
fontVariantNumeric: 'tabular-nums',
|
|
536
|
+
},
|
|
537
|
+
code: {
|
|
538
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
539
|
+
fontSize: 11.5,
|
|
540
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
541
|
+
overflowWrap: 'anywhere',
|
|
542
|
+
},
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function TextField({ label, value, onChange, type, placeholder, disabled }) {
|
|
546
|
+
return h('label', { style: styles.field },
|
|
547
|
+
h('span', { style: styles.label }, label),
|
|
548
|
+
h('input', {
|
|
549
|
+
style: styles.input,
|
|
550
|
+
type: type || 'text',
|
|
551
|
+
value: value == null ? '' : value,
|
|
552
|
+
placeholder: placeholder || '',
|
|
553
|
+
disabled: disabled === true,
|
|
554
|
+
onChange: (event) => onChange(event.target.value),
|
|
555
|
+
}),
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function SelectField({ label, value, onChange, options }) {
|
|
560
|
+
return h('label', { style: styles.field },
|
|
561
|
+
h('span', { style: styles.label }, label),
|
|
562
|
+
h('select', {
|
|
563
|
+
style: styles.select,
|
|
564
|
+
value,
|
|
565
|
+
onChange: (event) => onChange(event.target.value),
|
|
566
|
+
}, options.map((option) => h('option', { key: option.value, value: option.value }, option.label))),
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ---------------------------------------------------------------------
|
|
571
|
+
// Page
|
|
572
|
+
// ---------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
function MnemonSyncPage(props) {
|
|
575
|
+
// The Host's translator takes only a key, so placeholders are filled here.
|
|
576
|
+
// Messages such as "would write {count} files" need their values, and a
|
|
577
|
+
// bare `{count}` in the UI would look like a bug because it is one.
|
|
578
|
+
const rawT = props && props.t ? props.t : (key) => (zh[key] || key)
|
|
579
|
+
const t = (key, params) => {
|
|
580
|
+
const text = String(rawT(key))
|
|
581
|
+
if (params === undefined) return text
|
|
582
|
+
return text.replace(/\{(\w+)\}/gu, (whole, name) => (
|
|
583
|
+
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : whole
|
|
584
|
+
))
|
|
585
|
+
}
|
|
586
|
+
const [state, setState] = React.useState(null)
|
|
587
|
+
const [error, setError] = React.useState(null)
|
|
588
|
+
const [draft, setDraft] = React.useState(null)
|
|
589
|
+
const [busy, setBusy] = React.useState(false)
|
|
590
|
+
const [notice, setNotice] = React.useState(null)
|
|
591
|
+
|
|
592
|
+
// Backups on the target are read on demand, never on mount: listing costs
|
|
593
|
+
// a request against the user's storage and may be slow or denied.
|
|
594
|
+
const [archives, setArchives] = React.useState([])
|
|
595
|
+
const [archivesLoaded, setArchivesLoaded] = React.useState(false)
|
|
596
|
+
const [archivesBusy, setArchivesBusy] = React.useState(false)
|
|
597
|
+
const [archivesError, setArchivesError] = React.useState(null)
|
|
598
|
+
const [selectedArchive, setSelectedArchive] = React.useState(null)
|
|
599
|
+
const [inspection, setInspection] = React.useState(null)
|
|
600
|
+
const [inspectBusy, setInspectBusy] = React.useState(false)
|
|
601
|
+
const [restoreBusy, setRestoreBusy] = React.useState(false)
|
|
602
|
+
const [restoreResult, setRestoreResult] = React.useState(null)
|
|
603
|
+
// Which components of the inspected archive to bring back. `null` means
|
|
604
|
+
// "not chosen yet"; once an archive is inspected it starts with every
|
|
605
|
+
// component that archive holds.
|
|
606
|
+
const [restoreComponents, setRestoreComponents] = React.useState(null)
|
|
607
|
+
|
|
608
|
+
const load = React.useCallback(async () => {
|
|
609
|
+
setError(null)
|
|
610
|
+
try {
|
|
611
|
+
const body = await api('/status')
|
|
612
|
+
setState(body)
|
|
613
|
+
setDraft(body.settings)
|
|
614
|
+
} catch (cause) {
|
|
615
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
616
|
+
}
|
|
617
|
+
}, [])
|
|
618
|
+
|
|
619
|
+
React.useEffect(() => { void load() }, [load, props && props.refreshKey])
|
|
620
|
+
|
|
621
|
+
const patchDraft = (section, patch) => {
|
|
622
|
+
setDraft((current) => {
|
|
623
|
+
if (current == null) return current
|
|
624
|
+
if (section == null) return { ...current, ...patch }
|
|
625
|
+
return { ...current, [section]: { ...current[section], ...patch } }
|
|
626
|
+
})
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const patchTarget = (patch) => patchDraft('target', patch)
|
|
630
|
+
|
|
631
|
+
const save = async () => {
|
|
632
|
+
if (draft == null) return
|
|
633
|
+
setBusy(true)
|
|
634
|
+
setNotice(null)
|
|
635
|
+
try {
|
|
636
|
+
const body = await api('/settings', { method: 'PUT', body: JSON.stringify({ settings: draft }) })
|
|
637
|
+
setState((current) => (current == null ? current : { ...current, settings: body.settings }))
|
|
638
|
+
setDraft(body.settings)
|
|
639
|
+
setNotice({ ok: true, text: t('saved') })
|
|
640
|
+
} catch (cause) {
|
|
641
|
+
setNotice({ ok: false, text: `${t('saveFailed')}: ${cause.message}` })
|
|
642
|
+
} finally {
|
|
643
|
+
setBusy(false)
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const backupNow = async () => {
|
|
648
|
+
setBusy(true)
|
|
649
|
+
setNotice(null)
|
|
650
|
+
try {
|
|
651
|
+
// Send the draft so an unsaved change is honoured, not silently ignored.
|
|
652
|
+
const body = await api('/run', { method: 'POST', body: JSON.stringify({ settings: draft }) })
|
|
653
|
+
const result = body.result || {}
|
|
654
|
+
setNotice({
|
|
655
|
+
ok: true,
|
|
656
|
+
text: `${t('backupDone')} · ${humanBytes(result.bytes)} · ${result.key || ''}`,
|
|
657
|
+
})
|
|
658
|
+
await load()
|
|
659
|
+
} catch (cause) {
|
|
660
|
+
setNotice({ ok: false, text: `${t('backupFailed')}: ${cause.message}` })
|
|
661
|
+
await load()
|
|
662
|
+
} finally {
|
|
663
|
+
setBusy(false)
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Drop the run log. Archives on the target are not touched — this is a
|
|
669
|
+
* display reset, and the page says so before the button is pressed.
|
|
670
|
+
*/
|
|
671
|
+
const clearHistory = async () => {
|
|
672
|
+
setBusy(true)
|
|
673
|
+
setNotice(null)
|
|
674
|
+
try {
|
|
675
|
+
await api('/clear-history', { method: 'POST', body: '{}' })
|
|
676
|
+
await load()
|
|
677
|
+
setNotice({ ok: true, text: t('historyCleared') })
|
|
678
|
+
} catch (cause) {
|
|
679
|
+
setNotice({ ok: false, text: `${t('clearFailed')}: ${cause.message}` })
|
|
680
|
+
} finally {
|
|
681
|
+
setBusy(false)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const loadArchives = async () => {
|
|
686
|
+
setArchivesBusy(true)
|
|
687
|
+
setArchivesError(null)
|
|
688
|
+
try {
|
|
689
|
+
const body = await api('/archives')
|
|
690
|
+
setArchives(body.archives || [])
|
|
691
|
+
setArchivesLoaded(true)
|
|
692
|
+
} catch (cause) {
|
|
693
|
+
setArchivesError(cause.message)
|
|
694
|
+
setArchivesLoaded(true)
|
|
695
|
+
} finally {
|
|
696
|
+
setArchivesBusy(false)
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Read one archive and show what it holds.
|
|
702
|
+
*
|
|
703
|
+
* The component selection starts as "everything this archive has", which
|
|
704
|
+
* is the safe default for a restore: the operator picks what to bring
|
|
705
|
+
* back here, independently of what the backup scope currently says.
|
|
706
|
+
*/
|
|
707
|
+
const inspect = async (key) => {
|
|
708
|
+
setSelectedArchive(key)
|
|
709
|
+
setInspectBusy(true)
|
|
710
|
+
setInspection(null)
|
|
711
|
+
setRestoreResult(null)
|
|
712
|
+
setRestoreComponents(null)
|
|
713
|
+
try {
|
|
714
|
+
const body = await api('/inspect-archive', {
|
|
715
|
+
method: 'POST',
|
|
716
|
+
body: JSON.stringify({ key, components: draft.components }),
|
|
717
|
+
})
|
|
718
|
+
const archiveComponents = Array.isArray(body.archiveComponents) ? body.archiveComponents : []
|
|
719
|
+
setInspection({ ...body, plan: body.plan || [] })
|
|
720
|
+
setRestoreComponents(archiveComponents)
|
|
721
|
+
} catch (cause) {
|
|
722
|
+
setInspection(null)
|
|
723
|
+
setArchivesError(cause.message)
|
|
724
|
+
} finally {
|
|
725
|
+
setInspectBusy(false)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Which components of the inspected archive to restore.
|
|
731
|
+
*
|
|
732
|
+
* Kept separate from the backup scope on purpose: what you back up and
|
|
733
|
+
* what you bring back are two different decisions, and coupling them made
|
|
734
|
+
* it impossible to restore one component out of a full archive.
|
|
735
|
+
*/
|
|
736
|
+
const restoreSet = restoreComponents || []
|
|
737
|
+
const toggleRestoreComponent = (component, on) => {
|
|
738
|
+
const currentValue = restoreSet
|
|
739
|
+
const next = on
|
|
740
|
+
? [...new Set([...currentValue, component])]
|
|
741
|
+
: currentValue.filter((value) => value !== component)
|
|
742
|
+
setRestoreComponents(next)
|
|
743
|
+
// A stale plan next to a changed selection would describe the wrong work.
|
|
744
|
+
setRestoreResult(null)
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Restore the inspected archive. `apply=false` is a dry run that reports
|
|
749
|
+
* the plan; `apply=true` writes it. Keeping both behind the same call
|
|
750
|
+
* means the plan shown and the work done cannot diverge.
|
|
751
|
+
*/
|
|
752
|
+
const runRestore = async (apply) => {
|
|
753
|
+
if (inspection === null) return
|
|
754
|
+
if (restoreSet.length === 0) {
|
|
755
|
+
setRestoreResult({ ok: false, text: t('restorePickOne') })
|
|
756
|
+
return
|
|
757
|
+
}
|
|
758
|
+
setRestoreBusy(true)
|
|
759
|
+
setRestoreResult(null)
|
|
760
|
+
try {
|
|
761
|
+
const body = await api('/restore', {
|
|
762
|
+
method: 'POST',
|
|
763
|
+
body: JSON.stringify({
|
|
764
|
+
key: inspection.key,
|
|
765
|
+
components: restoreSet,
|
|
766
|
+
apply,
|
|
767
|
+
}),
|
|
768
|
+
})
|
|
769
|
+
if (body.missing && body.missing.length > 0 && !body.applied) {
|
|
770
|
+
setRestoreResult({
|
|
771
|
+
ok: false,
|
|
772
|
+
text: `${t('missingBlocksRestore')} ${body.missing.map((item) => componentLabel(t, item.component)).join('、')}`,
|
|
773
|
+
})
|
|
774
|
+
return
|
|
775
|
+
}
|
|
776
|
+
setRestoreResult({
|
|
777
|
+
ok: true,
|
|
778
|
+
text: body.applied
|
|
779
|
+
? t('restoreDone', { count: (body.written || []).length, size: humanBytes(body.writtenBytes) })
|
|
780
|
+
: t('restorePlan', { count: (body.plan || []).length, size: humanBytes(body.planBytes) }),
|
|
781
|
+
})
|
|
782
|
+
// Show what the chosen subset would write, so the preview matches the
|
|
783
|
+
// selection instead of the whole archive.
|
|
784
|
+
if (!body.applied && Array.isArray(body.plan)) {
|
|
785
|
+
setInspection((current) => (current === null ? current : { ...current, plan: body.plan, planBytes: body.planBytes }))
|
|
786
|
+
}
|
|
787
|
+
if (body.applied) await load()
|
|
788
|
+
} catch (cause) {
|
|
789
|
+
setRestoreResult({ ok: false, text: `${t('restoreFailed')}: ${cause.message}` })
|
|
790
|
+
} finally {
|
|
791
|
+
setRestoreBusy(false)
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (error != null) {
|
|
796
|
+
return h('div', { style: styles.page },
|
|
797
|
+
h('div', { style: { ...styles.banner, ...styles.bannerBad } }, `${t('loadFailed')}: ${error}`),
|
|
798
|
+
h('div', null, h('button', { style: styles.button, onClick: () => void load() }, t('retry'))),
|
|
799
|
+
)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (state == null || draft == null) {
|
|
803
|
+
return h('div', { style: styles.page }, h('p', { style: styles.note }, t('loading')))
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const spaces = state.state.spaces || []
|
|
807
|
+
const isS3 = draft.target.kind === 's3'
|
|
808
|
+
const history = state.history || []
|
|
809
|
+
|
|
810
|
+
/** Keep at least one component selected: a backup of nothing is not a backup. */
|
|
811
|
+
const toggleComponent = (id, on) => {
|
|
812
|
+
const currentValue = draft.components || []
|
|
813
|
+
const next = on
|
|
814
|
+
? [...new Set([...currentValue, id])]
|
|
815
|
+
: currentValue.filter((value) => value !== id)
|
|
816
|
+
if (next.length === 0) return
|
|
817
|
+
patchDraft(null, { components: next })
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// The same interval list dsh-mnemon's Git backup offers, so the two
|
|
821
|
+
// backup surfaces read alike: off, hourly, 3/6/12 hours, daily, 3 days,
|
|
822
|
+
// weekly.
|
|
823
|
+
const AUTO_SYNC_CHOICES = [0, 60, 180, 360, 720, 1440, 4320, 10080]
|
|
824
|
+
const intervalLabel = (minutes) => {
|
|
825
|
+
if (minutes <= 0) return t('autoOff')
|
|
826
|
+
if (minutes === 60) return t('autoHour')
|
|
827
|
+
if (minutes === 1440) return t('autoDay')
|
|
828
|
+
if (minutes % 1440 === 0) return t('autoDays', { days: minutes / 1440 })
|
|
829
|
+
return t('autoHours', { hours: minutes / 60 })
|
|
830
|
+
}
|
|
831
|
+
const autoOptions = AUTO_SYNC_CHOICES.map((choice) => ({
|
|
832
|
+
value: String(choice),
|
|
833
|
+
label: intervalLabel(choice),
|
|
834
|
+
}))
|
|
835
|
+
const autoMinutes = draft.autoSyncMinutes ?? 0
|
|
836
|
+
const scheduler = state.scheduler || {}
|
|
837
|
+
const autoHint = autoMinutes <= 0
|
|
838
|
+
? t('autoHintOff')
|
|
839
|
+
: [
|
|
840
|
+
scheduler.nextAt === undefined ? '' : t('autoNext', { time: stamp(scheduler.nextAt) }),
|
|
841
|
+
scheduler.lastAt === undefined ? '' : t('autoLast', { time: stamp(scheduler.lastAt) }),
|
|
842
|
+
].filter(Boolean).join(' · ') || t('autoHintOn')
|
|
843
|
+
|
|
844
|
+
return h('div', { style: styles.page },
|
|
845
|
+
|
|
846
|
+
// heading
|
|
847
|
+
h('div', { style: styles.header },
|
|
848
|
+
h('h2', { style: styles.title }, t('title')),
|
|
849
|
+
h('p', { style: styles.subtitle }, t('subtitle')),
|
|
850
|
+
),
|
|
851
|
+
|
|
852
|
+
notice == null ? null : h('div', {
|
|
853
|
+
style: { ...styles.banner, ...(notice.ok ? styles.bannerOk : styles.bannerBad) },
|
|
854
|
+
}, notice.text),
|
|
855
|
+
|
|
856
|
+
// Backup configuration: scope, target and cadence are one decision,
|
|
857
|
+
// so they share one card and one save. The save button lives at the
|
|
858
|
+
// bottom of the card, because it commits all three sections rather
|
|
859
|
+
// than the last one it happens to sit under.
|
|
860
|
+
h('section', { style: styles.card },
|
|
861
|
+
h('h3', { style: styles.cardTitle }, t('config')),
|
|
862
|
+
h('p', { style: styles.note }, t('configNote')),
|
|
863
|
+
|
|
864
|
+
// --- scope ---
|
|
865
|
+
h('div', { style: styles.group },
|
|
866
|
+
h('h4', { style: styles.groupTitle }, t('scope')),
|
|
867
|
+
h('p', { style: styles.note }, t('scopeNote')),
|
|
868
|
+
// One row per component, each showing whether the store actually has
|
|
869
|
+
// data for it. Selecting a component with no data is allowed — the
|
|
870
|
+
// archive records it as skipped — but the row says so up front.
|
|
871
|
+
h('div', { style: styles.componentList },
|
|
872
|
+
COMPONENT_ROWS.map((row) => {
|
|
873
|
+
const part = state.state.components ? state.state.components[row.id] : undefined
|
|
874
|
+
const available = part ? part.available === true : false
|
|
875
|
+
const checked = (draft.components || []).includes(row.id)
|
|
876
|
+
// The row shows a count, never the full list: a home with a dozen
|
|
877
|
+
// spaces would push the row's own name out of view. The names live
|
|
878
|
+
// in the title attribute, so hovering answers "which ones?".
|
|
879
|
+
const detail = available ? componentDetail(t, row.id, part) : t('componentEmpty')
|
|
880
|
+
return h('label', {
|
|
881
|
+
key: row.id,
|
|
882
|
+
style: styles.componentRow,
|
|
883
|
+
title: available ? componentTitle(row.id, part, state.spaceNames) : undefined,
|
|
884
|
+
},
|
|
885
|
+
h('input', {
|
|
886
|
+
type: 'checkbox',
|
|
887
|
+
checked,
|
|
888
|
+
onChange: (event) => toggleComponent(row.id, event.target.checked),
|
|
889
|
+
}),
|
|
890
|
+
h('span', { style: styles.componentText },
|
|
891
|
+
h('strong', { style: styles.componentName }, t(row.label)),
|
|
892
|
+
h('span', { style: styles.componentDetail }, detail),
|
|
893
|
+
),
|
|
894
|
+
h('span', { style: styles.componentSize }, available ? humanBytes(part.totalBytes) : '—'),
|
|
895
|
+
)
|
|
896
|
+
}),
|
|
897
|
+
),
|
|
898
|
+
h('p', { style: styles.note }, t('scopePath')),
|
|
899
|
+
h('span', { style: styles.code }, state.state.home || state.dataRoot),
|
|
900
|
+
),
|
|
901
|
+
|
|
902
|
+
// --- target ---
|
|
903
|
+
h('div', { style: styles.group },
|
|
904
|
+
h('h4', { style: styles.groupTitle }, t('target')),
|
|
905
|
+
h('div', { style: styles.row },
|
|
906
|
+
h(SelectField, {
|
|
907
|
+
label: t('kind'),
|
|
908
|
+
value: draft.target.kind,
|
|
909
|
+
onChange: (value) => patchTarget({ kind: value }),
|
|
910
|
+
options: [{ value: 'webdav', label: 'WebDAV' }, { value: 's3', label: 'S3' }],
|
|
911
|
+
}),
|
|
912
|
+
h(TextField, {
|
|
913
|
+
label: t('remotePath'),
|
|
914
|
+
value: draft.target.remotePath,
|
|
915
|
+
onChange: (value) => patchTarget({ remotePath: value }),
|
|
916
|
+
}),
|
|
917
|
+
),
|
|
918
|
+
|
|
919
|
+
isS3
|
|
920
|
+
? h(React.Fragment, null,
|
|
921
|
+
h('div', { style: styles.row },
|
|
922
|
+
h(TextField, { label: t('s3Endpoint'), value: draft.target.endpoint, placeholder: 'https://<account>.r2.cloudflarestorage.com', onChange: (v) => patchTarget({ endpoint: v }) }),
|
|
923
|
+
h(TextField, { label: t('s3Bucket'), value: draft.target.bucket, onChange: (v) => patchTarget({ bucket: v }) }),
|
|
924
|
+
h(TextField, { label: t('s3Region'), value: draft.target.region, placeholder: 'auto', onChange: (v) => patchTarget({ region: v }) }),
|
|
925
|
+
),
|
|
926
|
+
h('div', { style: styles.row },
|
|
927
|
+
h(TextField, { label: t('s3AccessKey'), value: draft.target.accessKeyId, onChange: (v) => patchTarget({ accessKeyId: v }) }),
|
|
928
|
+
h(TextField, { label: t('s3SecretKey'), type: 'password', value: draft.target.secretAccessKey, onChange: (v) => patchTarget({ secretAccessKey: v }) }),
|
|
929
|
+
),
|
|
930
|
+
h('div', { style: styles.row },
|
|
931
|
+
h(TextField, { label: t('s3SessionToken'), type: 'password', value: draft.target.sessionToken, onChange: (v) => patchTarget({ sessionToken: v }) }),
|
|
932
|
+
),
|
|
933
|
+
h('label', { style: styles.checkboxRow },
|
|
934
|
+
h('input', {
|
|
935
|
+
type: 'checkbox',
|
|
936
|
+
checked: draft.target.forcePathStyle === true,
|
|
937
|
+
onChange: (event) => patchTarget({ forcePathStyle: event.target.checked }),
|
|
938
|
+
}),
|
|
939
|
+
t('forcePathStyle'),
|
|
940
|
+
),
|
|
941
|
+
)
|
|
942
|
+
: h(React.Fragment, null,
|
|
943
|
+
h(TextField, { label: t('webdavUrl'), value: draft.target.url, placeholder: 'https://nas.example.com/dav', onChange: (v) => patchTarget({ url: v }) }),
|
|
944
|
+
h('div', { style: styles.row },
|
|
945
|
+
h(TextField, { label: t('webdavUser'), value: draft.target.username, onChange: (v) => patchTarget({ username: v }) }),
|
|
946
|
+
h(TextField, { label: t('webdavPassword'), type: 'password', value: draft.target.password, onChange: (v) => patchTarget({ password: v }) }),
|
|
947
|
+
),
|
|
948
|
+
),
|
|
949
|
+
|
|
950
|
+
h('p', { style: styles.note }, t('credentialsNote')),
|
|
951
|
+
h('p', { style: styles.note }, t('maskedHint')),
|
|
952
|
+
),
|
|
953
|
+
|
|
954
|
+
// --- cadence ---
|
|
955
|
+
h('div', { style: styles.group },
|
|
956
|
+
h('h4', { style: styles.groupTitle }, t('schedule')),
|
|
957
|
+
h('label', { style: styles.checkboxRow },
|
|
958
|
+
h('input', {
|
|
959
|
+
type: 'checkbox',
|
|
960
|
+
checked: draft.enabled === true,
|
|
961
|
+
onChange: (event) => patchDraft(null, { enabled: event.target.checked }),
|
|
962
|
+
}),
|
|
963
|
+
t('enabled'),
|
|
964
|
+
),
|
|
965
|
+
h('div', { style: styles.row },
|
|
966
|
+
h(SelectField, {
|
|
967
|
+
label: t('schedule'),
|
|
968
|
+
value: String(autoMinutes),
|
|
969
|
+
onChange: (value) => patchDraft(null, { autoSyncMinutes: Number(value) }),
|
|
970
|
+
options: autoOptions,
|
|
971
|
+
}),
|
|
972
|
+
h(TextField, {
|
|
973
|
+
label: t('retention'),
|
|
974
|
+
type: 'number',
|
|
975
|
+
value: draft.retention.keep,
|
|
976
|
+
onChange: (v) => patchDraft('retention', { keep: Number(v) }),
|
|
977
|
+
}),
|
|
978
|
+
),
|
|
979
|
+
// One line under the control, exactly as the Git backup block does it:
|
|
980
|
+
// what the cadence means, then when it will next run and when it last did.
|
|
981
|
+
h('p', { style: styles.note }, autoHint),
|
|
982
|
+
h('p', { style: styles.note }, t('retentionNote')),
|
|
983
|
+
),
|
|
984
|
+
|
|
985
|
+
// The one action that commits every section above.
|
|
986
|
+
h('div', { style: styles.saveBar },
|
|
987
|
+
h('button', { style: styles.primary, onClick: () => void save(), disabled: busy },
|
|
988
|
+
busy ? t('saving') : t('save')),
|
|
989
|
+
h('button', { style: styles.button, onClick: () => void backupNow(), disabled: busy },
|
|
990
|
+
busy ? t('backingUp') : t('backupNow')),
|
|
991
|
+
),
|
|
992
|
+
),
|
|
993
|
+
|
|
994
|
+
// backups on the target
|
|
995
|
+
h('section', { style: styles.card },
|
|
996
|
+
h('div', { style: styles.row },
|
|
997
|
+
h('h3', { style: { ...styles.cardTitle, marginRight: 'auto' } }, t('backups')),
|
|
998
|
+
h('button', { style: styles.button, onClick: () => void loadArchives(), disabled: archivesBusy },
|
|
999
|
+
archivesBusy ? t('loading') : (archivesLoaded ? t('refresh') : t('listBackups'))),
|
|
1000
|
+
),
|
|
1001
|
+
archivesError === null ? null : h('div', { style: { ...styles.banner, ...styles.bannerBad } }, archivesError),
|
|
1002
|
+
!archivesLoaded
|
|
1003
|
+
? h('p', { style: styles.note }, t('backupsHint'))
|
|
1004
|
+
: archives.length === 0
|
|
1005
|
+
? h('p', { style: styles.note }, t('noBackups'))
|
|
1006
|
+
: h('table', { style: styles.table },
|
|
1007
|
+
h('thead', null,
|
|
1008
|
+
h('tr', null,
|
|
1009
|
+
h('th', { style: styles.th }, t('backupName')),
|
|
1010
|
+
h('th', { style: styles.th }, t('size')),
|
|
1011
|
+
h('th', { style: styles.th }, t('backupTime')),
|
|
1012
|
+
h('th', { style: styles.th }, ''),
|
|
1013
|
+
),
|
|
1014
|
+
),
|
|
1015
|
+
h('tbody', null,
|
|
1016
|
+
archives.map((archive) => h('tr', {
|
|
1017
|
+
key: archive.key,
|
|
1018
|
+
style: selectedArchive === archive.key ? styles.selectedRow : undefined,
|
|
1019
|
+
},
|
|
1020
|
+
h('td', { style: { ...styles.td, ...styles.code } }, archive.key),
|
|
1021
|
+
h('td', { style: styles.td }, archive.bytes == null ? '—' : humanBytes(archive.bytes)),
|
|
1022
|
+
h('td', { style: styles.td }, archive.modifiedAt == null ? '—' : stamp(archive.modifiedAt)),
|
|
1023
|
+
h('td', { style: { ...styles.td, textAlign: 'right' } },
|
|
1024
|
+
h('button', {
|
|
1025
|
+
style: styles.button,
|
|
1026
|
+
disabled: inspectBusy,
|
|
1027
|
+
onClick: () => void inspect(archive.key),
|
|
1028
|
+
}, t('details')),
|
|
1029
|
+
),
|
|
1030
|
+
)),
|
|
1031
|
+
),
|
|
1032
|
+
),
|
|
1033
|
+
|
|
1034
|
+
// The detail panel: what the chosen archive holds, and what a restore
|
|
1035
|
+
// of the selected components would write. Missing components are called
|
|
1036
|
+
// out before anything is touched.
|
|
1037
|
+
inspection === null ? null : h('div', { style: styles.detail },
|
|
1038
|
+
h('div', { style: styles.row },
|
|
1039
|
+
h('strong', { style: styles.detailTitle }, inspection.key),
|
|
1040
|
+
h('span', { style: { ...styles.pill, ...(inspection.intact ? styles.pillOk : styles.pillBad) } },
|
|
1041
|
+
inspection.intact ? t('intact') : t('damaged')),
|
|
1042
|
+
),
|
|
1043
|
+
h('div', { style: styles.kv },
|
|
1044
|
+
h('span', { style: styles.kvKey }, t('backupTime')),
|
|
1045
|
+
h('span', { style: styles.kvValue }, inspection.createdAt == null ? '—' : stamp(inspection.createdAt)),
|
|
1046
|
+
h('span', { style: styles.kvKey }, t('contains')),
|
|
1047
|
+
h('span', { style: styles.kvValue },
|
|
1048
|
+
(inspection.archiveComponents || []).length === 0
|
|
1049
|
+
? '—'
|
|
1050
|
+
: inspection.archiveComponents.map((component) => h('span', {
|
|
1051
|
+
key: component,
|
|
1052
|
+
style: { ...styles.pill, marginRight: 6, marginBottom: 4 },
|
|
1053
|
+
}, componentLabel(t, component)))),
|
|
1054
|
+
h('span', { style: styles.kvKey }, t('files')),
|
|
1055
|
+
h('span', { style: styles.kvValue }, String(inspection.files ?? 0)),
|
|
1056
|
+
),
|
|
1057
|
+
|
|
1058
|
+
// What to bring back. Only components this archive actually holds are
|
|
1059
|
+
// offered, so a checkbox can never promise something that is not there.
|
|
1060
|
+
h('div', { style: styles.restorePicker },
|
|
1061
|
+
h('span', { style: styles.pickerTitle }, t('restorePick')),
|
|
1062
|
+
h('div', { style: styles.componentList },
|
|
1063
|
+
(inspection.archiveComponents || []).map((component) => h('label', {
|
|
1064
|
+
key: component,
|
|
1065
|
+
style: styles.componentRow,
|
|
1066
|
+
},
|
|
1067
|
+
h('input', {
|
|
1068
|
+
type: 'checkbox',
|
|
1069
|
+
checked: restoreSet.includes(component),
|
|
1070
|
+
onChange: (event) => toggleRestoreComponent(component, event.target.checked),
|
|
1071
|
+
}),
|
|
1072
|
+
h('span', { style: styles.componentText },
|
|
1073
|
+
h('strong', { style: styles.componentName }, componentLabel(t, component)),
|
|
1074
|
+
h('span', { style: styles.componentDetail }, describeRestoreTarget(component, inspection, t)),
|
|
1075
|
+
),
|
|
1076
|
+
)),
|
|
1077
|
+
),
|
|
1078
|
+
),
|
|
1079
|
+
|
|
1080
|
+
(inspection.missing || []).length === 0 ? null : h('div', { style: { ...styles.banner, ...styles.bannerWarn } },
|
|
1081
|
+
h('strong', null, `${t('missingWarn')} `),
|
|
1082
|
+
(inspection.missing || []).map((item) => `${componentLabel(t, item.component)}(${item.reason})`).join(';'),
|
|
1083
|
+
),
|
|
1084
|
+
|
|
1085
|
+
(inspection.plan || []).length === 0 ? null : h('div', null,
|
|
1086
|
+
h('p', { style: styles.note }, t('planHint', { count: (inspection.plan || []).length, size: humanBytes(inspection.planBytes) })),
|
|
1087
|
+
),
|
|
1088
|
+
|
|
1089
|
+
h('div', { style: styles.row },
|
|
1090
|
+
h('button', {
|
|
1091
|
+
style: styles.primary,
|
|
1092
|
+
disabled: restoreBusy || restoreSet.length === 0,
|
|
1093
|
+
onClick: () => void runRestore(false),
|
|
1094
|
+
}, restoreBusy ? t('restoring') : t('restoreDryRun')),
|
|
1095
|
+
h('button', {
|
|
1096
|
+
style: styles.dangerButton,
|
|
1097
|
+
disabled: restoreBusy || restoreSet.length === 0,
|
|
1098
|
+
onClick: () => void runRestore(true),
|
|
1099
|
+
}, t('restoreApply')),
|
|
1100
|
+
),
|
|
1101
|
+
|
|
1102
|
+
restoreResult === null ? null : h('div', {
|
|
1103
|
+
style: { ...styles.banner, ...(restoreResult.ok ? styles.bannerOk : styles.bannerBad) },
|
|
1104
|
+
}, restoreResult.text),
|
|
1105
|
+
),
|
|
1106
|
+
),
|
|
1107
|
+
|
|
1108
|
+
// history
|
|
1109
|
+
h('section', { style: styles.card },
|
|
1110
|
+
h('div', { style: styles.row },
|
|
1111
|
+
h('h3', { style: { ...styles.cardTitle, marginRight: 'auto' } }, t('history')),
|
|
1112
|
+
// The list keeps the newest 20 on its own; this clears what is left.
|
|
1113
|
+
history.length === 0 ? null : h('button', {
|
|
1114
|
+
style: styles.button,
|
|
1115
|
+
disabled: busy,
|
|
1116
|
+
onClick: () => void clearHistory(),
|
|
1117
|
+
}, t('clearHistory')),
|
|
1118
|
+
),
|
|
1119
|
+
history.length === 0
|
|
1120
|
+
? h('p', { style: styles.note }, t('noHistory'))
|
|
1121
|
+
: h('table', { style: styles.table },
|
|
1122
|
+
h('thead', null,
|
|
1123
|
+
h('tr', null,
|
|
1124
|
+
h('th', { style: styles.th }, t('runAt')),
|
|
1125
|
+
h('th', { style: styles.th }, t('trigger')),
|
|
1126
|
+
h('th', { style: styles.th }, t('componentsCount')),
|
|
1127
|
+
h('th', { style: styles.th }, t('size')),
|
|
1128
|
+
h('th', { style: styles.th }, t('duration')),
|
|
1129
|
+
h('th', { style: styles.th }, ''),
|
|
1130
|
+
),
|
|
1131
|
+
),
|
|
1132
|
+
h('tbody', null,
|
|
1133
|
+
history.map((run) => h('tr', { key: run.id },
|
|
1134
|
+
h('td', { style: styles.td }, stamp(run.finishedAt || run.startedAt)),
|
|
1135
|
+
h('td', { style: styles.td }, run.trigger === 'schedule' ? t('triggerSchedule') : t('triggerManual')),
|
|
1136
|
+
// What shipped, by component — the unit a backup is now made of.
|
|
1137
|
+
h('td', { style: styles.td }, run.components == null || run.components.length === 0 ? '—' : run.components.map((c) => componentLabel(t, c)).join('、')),
|
|
1138
|
+
h('td', { style: styles.td }, run.bytes == null ? '—' : humanBytes(run.bytes)),
|
|
1139
|
+
h('td', { style: styles.td }, run.durationMs == null ? '—' : `${Math.round(run.durationMs / 1000)}s`),
|
|
1140
|
+
h('td', { style: styles.td },
|
|
1141
|
+
run.ok
|
|
1142
|
+
? h('span', { style: { ...styles.pill, ...styles.pillOk } }, t('ok'))
|
|
1143
|
+
: h('span', { title: run.error || '', style: { ...styles.pill, ...styles.pillBad } }, t('failed')),
|
|
1144
|
+
),
|
|
1145
|
+
)),
|
|
1146
|
+
),
|
|
1147
|
+
),
|
|
1148
|
+
),
|
|
1149
|
+
)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// ---------------------------------------------------------------------
|
|
1153
|
+
// Plugin
|
|
1154
|
+
// ---------------------------------------------------------------------
|
|
1155
|
+
|
|
1156
|
+
return {
|
|
1157
|
+
inject: ['slots', 'locale'],
|
|
1158
|
+
apply(ctx) {
|
|
1159
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'mnemon-sync: dictionaries')
|
|
1160
|
+
const t = ctx.locale.bind(NS)
|
|
1161
|
+
|
|
1162
|
+
// The Memory Spaces workbench renders whatever registered under
|
|
1163
|
+
// `mnemon.source.page` for a running source type, so an extra page
|
|
1164
|
+
// appears as an extra tab beside the store's own.
|
|
1165
|
+
ctx.effect(() => {
|
|
1166
|
+
const mnemonClient = require('dsh-mnemon/client')
|
|
1167
|
+
if (mnemonClient == null || typeof mnemonClient.installMemorySourceUI !== 'function') {
|
|
1168
|
+
// dsh-mnemon is absent or older: stay silent rather than break the
|
|
1169
|
+
// plugin host. The Host half still runs and can be driven by API.
|
|
1170
|
+
return () => {}
|
|
1171
|
+
}
|
|
1172
|
+
return mnemonClient.installMemorySourceUI(ctx, {
|
|
1173
|
+
sourceTypeId: SOURCE_TYPE_ID,
|
|
1174
|
+
pages: [{
|
|
1175
|
+
id: PAGE_ID,
|
|
1176
|
+
order: 750,
|
|
1177
|
+
label: () => t('tabLabel'),
|
|
1178
|
+
navigation: {
|
|
1179
|
+
stickyHeader: false,
|
|
1180
|
+
group: 'tools',
|
|
1181
|
+
// The workbench renders a tab only for entries whose navigation
|
|
1182
|
+
// is primary, so this must stay true or the page exists without
|
|
1183
|
+
// a way to reach it.
|
|
1184
|
+
primary: true,
|
|
1185
|
+
glyph: '⇅',
|
|
1186
|
+
},
|
|
1187
|
+
component: (pageProps) => h(MnemonSyncPage, { ...pageProps, t }),
|
|
1188
|
+
}],
|
|
1189
|
+
})
|
|
1190
|
+
}, 'mnemon-sync: memory source page')
|
|
1191
|
+
},
|
|
1192
|
+
}
|
|
1193
|
+
},
|
|
1194
|
+
})
|