cursor-guard 4.0.0 → 4.2.0
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 +64 -17
- package/README.zh-CN.md +64 -17
- package/SKILL.md +4 -1
- package/package.json +5 -2
- package/references/bin/cursor-guard-init.js +122 -0
- package/references/dashboard/public/app.js +1068 -0
- package/references/dashboard/public/index.html +106 -0
- package/references/dashboard/public/style.css +666 -0
- package/references/dashboard/server.js +207 -0
- package/references/lib/core/doctor.js +48 -7
- package/references/lib/core/restore.js +25 -1
- package/references/lib/core/snapshot.js +6 -5
- package/references/lib/utils.js +7 -2
- package/references/mcp/server.js +9 -1
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
4
|
+
Cursor Guard Dashboard — Frontend
|
|
5
|
+
═══════════════════════════════════════════════════════════════ */
|
|
6
|
+
|
|
7
|
+
/* ── I18n Dictionary ──────────────────────────────────────── */
|
|
8
|
+
|
|
9
|
+
const I18N = {
|
|
10
|
+
'en-US': {
|
|
11
|
+
'app.title': 'Cursor Guard Dashboard',
|
|
12
|
+
'topbar.refresh': 'Refresh',
|
|
13
|
+
'topbar.lastRefresh':'Last refresh',
|
|
14
|
+
'state.loading': 'Loading…',
|
|
15
|
+
'state.retry': 'Retry',
|
|
16
|
+
|
|
17
|
+
'overview.title': 'Overview',
|
|
18
|
+
'backups.title': 'Backups & Recovery',
|
|
19
|
+
'protection.title': 'Protection Scope',
|
|
20
|
+
'diagnostics.title':'Diagnostics',
|
|
21
|
+
|
|
22
|
+
'health.title': 'Health',
|
|
23
|
+
'health.healthy': 'Healthy',
|
|
24
|
+
'health.warning': 'Warning',
|
|
25
|
+
'health.critical': 'Critical',
|
|
26
|
+
'health.unknown': 'Unknown',
|
|
27
|
+
|
|
28
|
+
'gitBackup.title': 'Latest Git Backup',
|
|
29
|
+
'gitBackup.none': 'No Git backup yet',
|
|
30
|
+
'shadowBackup.title':'Latest Shadow Snapshot',
|
|
31
|
+
'shadowBackup.none':'No Shadow snapshot yet',
|
|
32
|
+
|
|
33
|
+
'watcher.title': 'Watcher',
|
|
34
|
+
'watcher.running': 'Running',
|
|
35
|
+
'watcher.stopped': 'Stopped',
|
|
36
|
+
'watcher.stale': 'Stale',
|
|
37
|
+
'watcher.pid': 'PID',
|
|
38
|
+
'watcher.since': 'Since',
|
|
39
|
+
|
|
40
|
+
'alert.title': 'Alerts',
|
|
41
|
+
'alert.none': 'No active alerts',
|
|
42
|
+
'alert.active': 'Active Alert',
|
|
43
|
+
|
|
44
|
+
'backups.gitCommits': 'Git Commits',
|
|
45
|
+
'backups.shadowSnapshots': 'Shadow Snapshots',
|
|
46
|
+
'backups.preRestore': 'Pre-Restore Snapshots',
|
|
47
|
+
'backups.diskUsage': 'Disk Usage',
|
|
48
|
+
'backups.gitDisk': 'Git',
|
|
49
|
+
'backups.shadowDisk': 'Shadow',
|
|
50
|
+
'backups.restorePoints': 'Restore Points',
|
|
51
|
+
'backups.filterAll': 'All',
|
|
52
|
+
'backups.noBackups': 'No restore points found',
|
|
53
|
+
'backups.col.time': 'Time',
|
|
54
|
+
'backups.col.type': 'Type',
|
|
55
|
+
'backups.col.ref': 'Ref / Hash',
|
|
56
|
+
|
|
57
|
+
'type.git-auto-backup': 'Git Auto-Backup',
|
|
58
|
+
'type.git-pre-restore': 'Git Pre-Restore',
|
|
59
|
+
'type.git-snapshot': 'Git Snapshot',
|
|
60
|
+
'type.shadow': 'Shadow Snapshot',
|
|
61
|
+
'type.shadow-pre-restore': 'Shadow Pre-Restore',
|
|
62
|
+
|
|
63
|
+
'protection.protect': 'Protected Patterns',
|
|
64
|
+
'protection.ignore': 'Ignored Patterns',
|
|
65
|
+
'protection.fileCount': '{n} files in protection scope',
|
|
66
|
+
'protection.note': 'These patterns define which files are protected. This is not the full directory listing.',
|
|
67
|
+
'protection.allFiles': 'All files (no protect patterns configured)',
|
|
68
|
+
'protection.noIgnore': 'None',
|
|
69
|
+
|
|
70
|
+
'diagnostics.pass': 'Pass',
|
|
71
|
+
'diagnostics.warn': 'Warn',
|
|
72
|
+
'diagnostics.fail': 'Fail',
|
|
73
|
+
'diagnostics.hint': 'Click for details →',
|
|
74
|
+
'diagnostics.PASS': 'PASS',
|
|
75
|
+
'diagnostics.WARN': 'WARN',
|
|
76
|
+
'diagnostics.FAIL': 'FAIL',
|
|
77
|
+
|
|
78
|
+
'drawer.restorePoint': 'Restore Point Details',
|
|
79
|
+
'drawer.doctorTitle': 'Diagnostic Details',
|
|
80
|
+
'drawer.close': 'Close',
|
|
81
|
+
'drawer.preview': 'Preview JSON',
|
|
82
|
+
'drawer.copyRef': 'Copy Ref',
|
|
83
|
+
'drawer.copyJson': 'Copy JSON',
|
|
84
|
+
'drawer.copied': 'Copied!',
|
|
85
|
+
'drawer.field.time': 'Time',
|
|
86
|
+
'drawer.field.type': 'Type',
|
|
87
|
+
'drawer.field.ref': 'Ref',
|
|
88
|
+
'drawer.field.hash': 'Commit Hash',
|
|
89
|
+
'drawer.field.path': 'Path',
|
|
90
|
+
'drawer.field.message': 'Message',
|
|
91
|
+
|
|
92
|
+
'error.fetchFailed': 'Failed to fetch data',
|
|
93
|
+
'error.sectionFailed': 'This section failed to load',
|
|
94
|
+
'empty.noData': 'No data available',
|
|
95
|
+
|
|
96
|
+
'strategy.git': 'Git',
|
|
97
|
+
'strategy.shadow': 'Shadow',
|
|
98
|
+
'strategy.both': 'Both',
|
|
99
|
+
|
|
100
|
+
'time.justNow': 'just now',
|
|
101
|
+
'time.secondsAgo': '{n}s ago',
|
|
102
|
+
'time.minutesAgo': '{n}m ago',
|
|
103
|
+
'time.hoursAgo': '{n}h ago',
|
|
104
|
+
'time.daysAgo': '{n}d ago',
|
|
105
|
+
|
|
106
|
+
'issue.watcher_not_running': 'Auto-backup watcher is not running',
|
|
107
|
+
'issue.watcher_stale': 'Watcher has a stale lock file (process not running)',
|
|
108
|
+
'issue.strategy_no_git': 'Strategy requires Git but directory is not a git repo',
|
|
109
|
+
'issue.no_auto_backup_ref': 'No auto-backup ref found — watcher may not have run yet',
|
|
110
|
+
'issue.disk_critically_low': 'Disk space critically low ({gb} GB free)',
|
|
111
|
+
'issue.disk_low': 'Disk space low ({gb} GB free)',
|
|
112
|
+
'issue.git_backup_stale': 'Last git backup is stale ({rel})',
|
|
113
|
+
'issue.active_alert': 'Active alert: {type} — {count} files in {window}s',
|
|
114
|
+
'issue.alert_high_velocity': 'High volume of file changes detected. Consider reviewing recent modifications and creating a manual snapshot.',
|
|
115
|
+
|
|
116
|
+
'check.Git installed': 'Git installed',
|
|
117
|
+
'check.Git repository': 'Git repository',
|
|
118
|
+
'check.Config file': 'Config file',
|
|
119
|
+
'check.Strategy compatibility': 'Strategy compatibility',
|
|
120
|
+
'check.Backup ref': 'Backup ref',
|
|
121
|
+
'check.Guard refs': 'Guard refs',
|
|
122
|
+
'check.Shadow copies': 'Shadow copies',
|
|
123
|
+
'check.Backup dir ignored': 'Backup dir ignored',
|
|
124
|
+
'check.Config: backup_strategy': 'Config: backup_strategy',
|
|
125
|
+
'check.Config: pre_restore_backup': 'Config: pre_restore_backup',
|
|
126
|
+
'check.Config: interval': 'Config: interval',
|
|
127
|
+
'check.Config: retention.mode': 'Config: retention.mode',
|
|
128
|
+
'check.Config: git_retention.mode': 'Config: git_retention.mode',
|
|
129
|
+
'check.Protect patterns': 'Protect patterns',
|
|
130
|
+
'check.Disk space': 'Disk space',
|
|
131
|
+
'check.Lock file': 'Lock file',
|
|
132
|
+
'check.Node.js': 'Node.js',
|
|
133
|
+
'check.MCP server': 'MCP server',
|
|
134
|
+
'check.MCP version': 'MCP version',
|
|
135
|
+
|
|
136
|
+
'detail.git_version': 'version {v}',
|
|
137
|
+
'detail.git_not_found': 'git not found in PATH; only shadow strategy available',
|
|
138
|
+
'detail.worktree': 'worktree detected (git-dir: {dir})',
|
|
139
|
+
'detail.standard_repo': 'standard repo',
|
|
140
|
+
'detail.not_git_repo': 'not a Git repo; git/both strategies won\'t work',
|
|
141
|
+
'detail.config_valid': '.cursor-guard.json found and valid JSON',
|
|
142
|
+
'detail.config_parse_error': 'JSON parse error: {err}',
|
|
143
|
+
'detail.config_missing': 'no .cursor-guard.json found; using defaults (protect everything)',
|
|
144
|
+
'detail.strategy_no_git': 'backup_strategy=\'{s}\' but directory is not a Git repo',
|
|
145
|
+
'detail.strategy_ok': 'backup_strategy=\'{s}\' and Git repo exists',
|
|
146
|
+
'detail.strategy_shadow': 'backup_strategy=\'shadow\' — no Git required',
|
|
147
|
+
'detail.strategy_unknown': 'unknown backup_strategy=\'{s}\' (must be git/shadow/both)',
|
|
148
|
+
'detail.ref_exists': 'refs/guard/auto-backup exists ({n} commits)',
|
|
149
|
+
'detail.ref_legacy': 'legacy refs/heads/cursor-guard/auto-backup found ({n} commits) — run auto-backup once to migrate',
|
|
150
|
+
'detail.ref_not_created': 'refs/guard/auto-backup not created yet (will be created on first backup)',
|
|
151
|
+
'detail.guard_refs_found': '{n} ref(s) found ({pre} pre-restore snapshots)',
|
|
152
|
+
'detail.guard_refs_none': 'no guard refs yet (created on first snapshot or restore)',
|
|
153
|
+
'detail.shadow_stats': '{n} snapshot(s), {mb} MB total',
|
|
154
|
+
'detail.shadow_not_found': '.cursor-guard-backup/ not found (will be created on first shadow backup)',
|
|
155
|
+
'detail.gitignore_ok': '.cursor-guard-backup/ is git-ignored',
|
|
156
|
+
'detail.gitignore_missing': '.cursor-guard-backup/ may NOT be git-ignored — backup changes could trigger commits',
|
|
157
|
+
'detail.invalid_value': 'invalid value \'{v}\'',
|
|
158
|
+
'detail.pre_restore_never': 'set to \'never\' — restores won\'t auto-preserve current version',
|
|
159
|
+
'detail.interval_low': '{n}s is below minimum (5s), will be clamped',
|
|
160
|
+
'detail.protect_count': '{matched} / {total} files matched by protect patterns',
|
|
161
|
+
'detail.disk_critical': '{gb} GB free — critically low',
|
|
162
|
+
'detail.disk_free': '{gb} GB free',
|
|
163
|
+
'detail.disk_unknown': 'could not determine free space',
|
|
164
|
+
'detail.lock_exists': 'lock file exists — another instance may be running. {info}',
|
|
165
|
+
'detail.lock_none': 'no lock file (no running instance)',
|
|
166
|
+
'detail.node_ok': '{v}',
|
|
167
|
+
'detail.node_old': '{v} — recommended >=18',
|
|
168
|
+
'detail.mcp_ok': 'server.js found, SDK {v}',
|
|
169
|
+
'detail.mcp_no_sdk': 'server.js found but @modelcontextprotocol/sdk not installed — run: cd <skill-dir> && npm install',
|
|
170
|
+
'detail.mcp_no_server': 'SDK installed ({v}) but server.js not found at expected path',
|
|
171
|
+
'detail.mcp_not_configured': 'MCP not configured (optional — cursor-guard works without it)',
|
|
172
|
+
'detail.mcp_version_mismatch': 'running v{mem} but disk has v{disk} — restart Cursor to load the new version',
|
|
173
|
+
'detail.mcp_version_ok': 'v{v}',
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
'zh-CN': {
|
|
177
|
+
'app.title': 'Cursor Guard 仪表盘',
|
|
178
|
+
'topbar.refresh': '刷新',
|
|
179
|
+
'topbar.lastRefresh':'上次刷新',
|
|
180
|
+
'state.loading': '加载中…',
|
|
181
|
+
'state.retry': '重试',
|
|
182
|
+
|
|
183
|
+
'overview.title': '总览',
|
|
184
|
+
'backups.title': '备份与恢复',
|
|
185
|
+
'protection.title': '保护范围',
|
|
186
|
+
'diagnostics.title':'诊断',
|
|
187
|
+
|
|
188
|
+
'health.title': '健康状态',
|
|
189
|
+
'health.healthy': '健康',
|
|
190
|
+
'health.warning': '警告',
|
|
191
|
+
'health.critical': '严重',
|
|
192
|
+
'health.unknown': '未知',
|
|
193
|
+
|
|
194
|
+
'gitBackup.title': '最近 Git 备份',
|
|
195
|
+
'gitBackup.none': '暂无 Git 备份',
|
|
196
|
+
'shadowBackup.title':'最近影子快照',
|
|
197
|
+
'shadowBackup.none':'暂无影子快照',
|
|
198
|
+
|
|
199
|
+
'watcher.title': '守护进程',
|
|
200
|
+
'watcher.running': '运行中',
|
|
201
|
+
'watcher.stopped': '已停止',
|
|
202
|
+
'watcher.stale': '已过期',
|
|
203
|
+
'watcher.pid': 'PID',
|
|
204
|
+
'watcher.since': '启动时间',
|
|
205
|
+
|
|
206
|
+
'alert.title': '告警',
|
|
207
|
+
'alert.none': '无活跃告警',
|
|
208
|
+
'alert.active': '活跃告警',
|
|
209
|
+
|
|
210
|
+
'backups.gitCommits': 'Git 提交数',
|
|
211
|
+
'backups.shadowSnapshots': '影子快照',
|
|
212
|
+
'backups.preRestore': '恢复前快照',
|
|
213
|
+
'backups.diskUsage': '磁盘占用',
|
|
214
|
+
'backups.gitDisk': 'Git',
|
|
215
|
+
'backups.shadowDisk': 'Shadow',
|
|
216
|
+
'backups.restorePoints': '恢复点',
|
|
217
|
+
'backups.filterAll': '全部',
|
|
218
|
+
'backups.noBackups': '暂无恢复点',
|
|
219
|
+
'backups.col.time': '时间',
|
|
220
|
+
'backups.col.type': '类型',
|
|
221
|
+
'backups.col.ref': '引用 / Hash',
|
|
222
|
+
|
|
223
|
+
'type.git-auto-backup': 'Git 自动备份',
|
|
224
|
+
'type.git-pre-restore': 'Git 恢复前快照',
|
|
225
|
+
'type.git-snapshot': 'Git 快照',
|
|
226
|
+
'type.shadow': '影子快照',
|
|
227
|
+
'type.shadow-pre-restore': '影子恢复前快照',
|
|
228
|
+
|
|
229
|
+
'protection.protect': '保护规则',
|
|
230
|
+
'protection.ignore': '忽略规则',
|
|
231
|
+
'protection.fileCount': '{n} 个文件在保护范围内',
|
|
232
|
+
'protection.note': '以下是当前会进入保护范围的文件规则,不等于当前目录全部文件。',
|
|
233
|
+
'protection.allFiles': '全部文件(未配置 protect 规则)',
|
|
234
|
+
'protection.noIgnore': '无',
|
|
235
|
+
|
|
236
|
+
'diagnostics.pass': '通过',
|
|
237
|
+
'diagnostics.warn': '警告',
|
|
238
|
+
'diagnostics.fail': '失败',
|
|
239
|
+
'diagnostics.hint': '点击查看详情 →',
|
|
240
|
+
'diagnostics.PASS': '通过',
|
|
241
|
+
'diagnostics.WARN': '警告',
|
|
242
|
+
'diagnostics.FAIL': '失败',
|
|
243
|
+
|
|
244
|
+
'drawer.restorePoint': '恢复点详情',
|
|
245
|
+
'drawer.doctorTitle': '诊断详情',
|
|
246
|
+
'drawer.close': '关闭',
|
|
247
|
+
'drawer.preview': '预览 JSON',
|
|
248
|
+
'drawer.copyRef': '复制引用',
|
|
249
|
+
'drawer.copyJson': '复制 JSON',
|
|
250
|
+
'drawer.copied': '已复制!',
|
|
251
|
+
'drawer.field.time': '时间',
|
|
252
|
+
'drawer.field.type': '类型',
|
|
253
|
+
'drawer.field.ref': '引用',
|
|
254
|
+
'drawer.field.hash': '提交 Hash',
|
|
255
|
+
'drawer.field.path': '路径',
|
|
256
|
+
'drawer.field.message': '消息',
|
|
257
|
+
|
|
258
|
+
'error.fetchFailed': '数据拉取失败',
|
|
259
|
+
'error.sectionFailed': '此区块加载失败',
|
|
260
|
+
'empty.noData': '暂无数据',
|
|
261
|
+
|
|
262
|
+
'strategy.git': 'Git',
|
|
263
|
+
'strategy.shadow': '影子',
|
|
264
|
+
'strategy.both': '双重',
|
|
265
|
+
|
|
266
|
+
'time.justNow': '刚刚',
|
|
267
|
+
'time.secondsAgo': '{n} 秒前',
|
|
268
|
+
'time.minutesAgo': '{n} 分钟前',
|
|
269
|
+
'time.hoursAgo': '{n} 小时前',
|
|
270
|
+
'time.daysAgo': '{n} 天前',
|
|
271
|
+
|
|
272
|
+
'issue.watcher_not_running': '自动备份守护进程未运行',
|
|
273
|
+
'issue.watcher_stale': '守护进程锁文件已过期(进程未运行)',
|
|
274
|
+
'issue.strategy_no_git': '策略需要 Git 但目录不是 Git 仓库',
|
|
275
|
+
'issue.no_auto_backup_ref': '未找到自动备份引用——守护进程可能尚未运行',
|
|
276
|
+
'issue.disk_critically_low': '磁盘空间严重不足({gb} GB 可用)',
|
|
277
|
+
'issue.disk_low': '磁盘空间不足({gb} GB 可用)',
|
|
278
|
+
'issue.git_backup_stale': '最近 Git 备份已过时({rel})',
|
|
279
|
+
'issue.active_alert': '活跃告警:{type}——{count} 个文件在 {window} 秒内变更',
|
|
280
|
+
'issue.alert_high_velocity': '检测到大量文件变更,建议检查最近修改并手动创建快照。',
|
|
281
|
+
|
|
282
|
+
'check.Git installed': 'Git 安装状态',
|
|
283
|
+
'check.Git repository': 'Git 仓库',
|
|
284
|
+
'check.Config file': '配置文件',
|
|
285
|
+
'check.Strategy compatibility': '策略兼容性',
|
|
286
|
+
'check.Backup ref': '备份引用',
|
|
287
|
+
'check.Guard refs': 'Guard 引用',
|
|
288
|
+
'check.Shadow copies': '影子拷贝',
|
|
289
|
+
'check.Backup dir ignored': '备份目录忽略',
|
|
290
|
+
'check.Config: backup_strategy': '配置:备份策略',
|
|
291
|
+
'check.Config: pre_restore_backup': '配置:恢复前备份',
|
|
292
|
+
'check.Config: interval': '配置:备份间隔',
|
|
293
|
+
'check.Config: retention.mode': '配置:留存模式',
|
|
294
|
+
'check.Config: git_retention.mode': '配置:Git 留存模式',
|
|
295
|
+
'check.Protect patterns': '保护规则匹配',
|
|
296
|
+
'check.Disk space': '磁盘空间',
|
|
297
|
+
'check.Lock file': '锁文件',
|
|
298
|
+
'check.Node.js': 'Node.js',
|
|
299
|
+
'check.MCP server': 'MCP 服务器',
|
|
300
|
+
'check.MCP version': 'MCP 版本',
|
|
301
|
+
|
|
302
|
+
'detail.git_version': '版本 {v}',
|
|
303
|
+
'detail.git_not_found': 'PATH 中未找到 git;仅可使用 shadow 策略',
|
|
304
|
+
'detail.worktree': '检测到工作树(git-dir:{dir})',
|
|
305
|
+
'detail.standard_repo': '标准仓库',
|
|
306
|
+
'detail.not_git_repo': '非 Git 仓库;git/both 策略不可用',
|
|
307
|
+
'detail.config_valid': '.cursor-guard.json 已找到且 JSON 格式有效',
|
|
308
|
+
'detail.config_parse_error': 'JSON 解析错误:{err}',
|
|
309
|
+
'detail.config_missing': '未找到 .cursor-guard.json;使用默认设置(保护全部文件)',
|
|
310
|
+
'detail.strategy_no_git': 'backup_strategy=\'{s}\' 但目录不是 Git 仓库',
|
|
311
|
+
'detail.strategy_ok': 'backup_strategy=\'{s}\' 且 Git 仓库存在',
|
|
312
|
+
'detail.strategy_shadow': 'backup_strategy=\'shadow\'——不需要 Git',
|
|
313
|
+
'detail.strategy_unknown': '未知 backup_strategy=\'{s}\'(须为 git/shadow/both)',
|
|
314
|
+
'detail.ref_exists': 'refs/guard/auto-backup 存在({n} 个提交)',
|
|
315
|
+
'detail.ref_legacy': '发现旧版 refs/heads/cursor-guard/auto-backup({n} 个提交)——运行一次自动备份即可迁移',
|
|
316
|
+
'detail.ref_not_created': 'refs/guard/auto-backup 尚未创建(首次备份时自动创建)',
|
|
317
|
+
'detail.guard_refs_found': '{n} 个引用({pre} 个恢复前快照)',
|
|
318
|
+
'detail.guard_refs_none': '尚无 guard 引用(首次快照或恢复时创建)',
|
|
319
|
+
'detail.shadow_stats': '{n} 个快照,共 {mb} MB',
|
|
320
|
+
'detail.shadow_not_found': '.cursor-guard-backup/ 未找到(首次影子备份时自动创建)',
|
|
321
|
+
'detail.gitignore_ok': '.cursor-guard-backup/ 已被 git 忽略',
|
|
322
|
+
'detail.gitignore_missing': '.cursor-guard-backup/ 可能未被 git 忽略——备份变更可能触发提交',
|
|
323
|
+
'detail.invalid_value': '无效值 \'{v}\'',
|
|
324
|
+
'detail.pre_restore_never': '设为 \'never\'——恢复时不会自动保留当前版本',
|
|
325
|
+
'detail.interval_low': '{n} 秒低于最小值(5 秒),将被限制',
|
|
326
|
+
'detail.protect_count': '{matched} / {total} 个文件匹配保护规则',
|
|
327
|
+
'detail.disk_critical': '{gb} GB 可用——严重不足',
|
|
328
|
+
'detail.disk_free': '{gb} GB 可用',
|
|
329
|
+
'detail.disk_unknown': '无法检测可用空间',
|
|
330
|
+
'detail.lock_exists': '锁文件存在——可能有其他实例正在运行。{info}',
|
|
331
|
+
'detail.lock_none': '无锁文件(无运行中的实例)',
|
|
332
|
+
'detail.node_ok': '{v}',
|
|
333
|
+
'detail.node_old': '{v}——建议 >=18',
|
|
334
|
+
'detail.mcp_ok': 'server.js 已找到,SDK {v}',
|
|
335
|
+
'detail.mcp_no_sdk': 'server.js 已找到但 @modelcontextprotocol/sdk 未安装——请运行:cd <skill-dir> && npm install',
|
|
336
|
+
'detail.mcp_no_server': 'SDK 已安装({v})但 server.js 未在预期路径找到',
|
|
337
|
+
'detail.mcp_not_configured': 'MCP 未配置(可选——cursor-guard 无需 MCP 也能工作)',
|
|
338
|
+
'detail.mcp_version_mismatch': '运行中 v{mem},磁盘为 v{disk}——请重启 Cursor 加载新版本',
|
|
339
|
+
'detail.mcp_version_ok': 'v{v}',
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/* ── State ────────────────────────────────────────────────── */
|
|
344
|
+
|
|
345
|
+
const state = {
|
|
346
|
+
locale: 'en-US',
|
|
347
|
+
projects: [],
|
|
348
|
+
currentProjectId: null,
|
|
349
|
+
pageData: null,
|
|
350
|
+
filteredBackups: [],
|
|
351
|
+
backupFilter: 'all',
|
|
352
|
+
refreshTimer: null,
|
|
353
|
+
tickTimer: null,
|
|
354
|
+
lastRefreshAt: null,
|
|
355
|
+
drawerOpen: null,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const REFRESH_MS = 15000;
|
|
359
|
+
|
|
360
|
+
/* ── DOM helpers ──────────────────────────────────────────── */
|
|
361
|
+
|
|
362
|
+
const $ = (s) => document.querySelector(s);
|
|
363
|
+
const $$ = (s) => document.querySelectorAll(s);
|
|
364
|
+
const show = (el) => el && el.classList.remove('hidden');
|
|
365
|
+
const hide = (el) => el && el.classList.add('hidden');
|
|
366
|
+
|
|
367
|
+
function esc(s) {
|
|
368
|
+
if (s == null) return '';
|
|
369
|
+
return String(s)
|
|
370
|
+
.replace(/&/g, '&').replace(/</g, '<')
|
|
371
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* ── I18n helpers ─────────────────────────────────────────── */
|
|
375
|
+
|
|
376
|
+
function t(key, params) {
|
|
377
|
+
const dict = I18N[state.locale] || I18N['en-US'];
|
|
378
|
+
let text = dict[key] || I18N['en-US'][key] || key;
|
|
379
|
+
if (params) {
|
|
380
|
+
for (const [k, v] of Object.entries(params)) {
|
|
381
|
+
text = text.replace(`{${k}}`, v);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return text;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function detectLocale() {
|
|
388
|
+
const saved = localStorage.getItem('cg-locale');
|
|
389
|
+
if (saved && I18N[saved]) return saved;
|
|
390
|
+
const nav = navigator.language || '';
|
|
391
|
+
return nav.startsWith('zh') ? 'zh-CN' : 'en-US';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function setLocale(loc) {
|
|
395
|
+
state.locale = loc;
|
|
396
|
+
localStorage.setItem('cg-locale', loc);
|
|
397
|
+
document.documentElement.lang = loc === 'zh-CN' ? 'zh-CN' : 'en';
|
|
398
|
+
document.title = t('app.title');
|
|
399
|
+
const refreshBtn = $('#refresh-btn');
|
|
400
|
+
if (refreshBtn) refreshBtn.title = t('topbar.refresh');
|
|
401
|
+
updateStaticI18n();
|
|
402
|
+
if (state.pageData) renderAll();
|
|
403
|
+
updateRefreshDisplay();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function updateStaticI18n() {
|
|
407
|
+
$$('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/* ── Backend string translation ────────────────────────────── */
|
|
411
|
+
|
|
412
|
+
const ISSUE_PATTERNS = [
|
|
413
|
+
{ re: /^Auto-backup watcher is not running$/, key: 'issue.watcher_not_running' },
|
|
414
|
+
{ re: /^Watcher has a stale lock file/, key: 'issue.watcher_stale' },
|
|
415
|
+
{ re: /^Strategy requires Git but directory is not a git repo$/, key: 'issue.strategy_no_git' },
|
|
416
|
+
{ re: /^No auto-backup ref found/, key: 'issue.no_auto_backup_ref' },
|
|
417
|
+
{ re: /^Disk space critically low \((.+?) GB free\)$/, key: 'issue.disk_critically_low', extract: ['gb'] },
|
|
418
|
+
{ re: /^Disk space low \((.+?) GB free\)$/, key: 'issue.disk_low', extract: ['gb'] },
|
|
419
|
+
{ re: /^Last git backup is stale \((.+?)\)$/, key: 'issue.git_backup_stale', extract: ['rel'] },
|
|
420
|
+
{ re: /^Active alert: (.+?) — (\d+) files in (\d+)s$/, key: 'issue.active_alert', extract: ['type', 'count', 'window'] },
|
|
421
|
+
{ re: /^High volume of file changes/, key: 'issue.alert_high_velocity' },
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
function translateIssue(text) {
|
|
425
|
+
for (const p of ISSUE_PATTERNS) {
|
|
426
|
+
const m = text.match(p.re);
|
|
427
|
+
if (m) {
|
|
428
|
+
const params = {};
|
|
429
|
+
if (p.extract) p.extract.forEach((k, i) => { params[k] = m[i + 1]; });
|
|
430
|
+
return t(p.key, params);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return text;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function translateCheckName(name) {
|
|
437
|
+
const key = 'check.' + name;
|
|
438
|
+
const translated = t(key);
|
|
439
|
+
return translated !== key ? translated : name;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const DETAIL_PATTERNS = [
|
|
443
|
+
{ re: /^version (.+)$/, key: 'detail.git_version', extract: ['v'] },
|
|
444
|
+
{ re: /^git not found in PATH/, key: 'detail.git_not_found' },
|
|
445
|
+
{ re: /^worktree detected \(git-dir: (.+)\)$/, key: 'detail.worktree', extract: ['dir'] },
|
|
446
|
+
{ re: /^standard repo$/, key: 'detail.standard_repo' },
|
|
447
|
+
{ re: /^not a Git repo/, key: 'detail.not_git_repo' },
|
|
448
|
+
{ re: /^\.cursor-guard\.json found and valid JSON$/, key: 'detail.config_valid' },
|
|
449
|
+
{ re: /^JSON parse error: (.+)$/, key: 'detail.config_parse_error', extract: ['err'] },
|
|
450
|
+
{ re: /^no \.cursor-guard\.json found/, key: 'detail.config_missing' },
|
|
451
|
+
{ re: /^backup_strategy='(.+?)' but directory is not a Git repo$/, key: 'detail.strategy_no_git', extract: ['s'] },
|
|
452
|
+
{ re: /^backup_strategy='(.+?)' and Git repo exists$/, key: 'detail.strategy_ok', extract: ['s'] },
|
|
453
|
+
{ re: /^backup_strategy='shadow'/, key: 'detail.strategy_shadow' },
|
|
454
|
+
{ re: /^unknown backup_strategy='(.+?)'/, key: 'detail.strategy_unknown', extract: ['s'] },
|
|
455
|
+
{ re: /^refs\/guard\/auto-backup exists \((.+?) commits?\)$/, key: 'detail.ref_exists', extract: ['n'] },
|
|
456
|
+
{ re: /^legacy refs\/heads\/cursor-guard\/auto-backup found \((.+?) commits?\)/,key: 'detail.ref_legacy', extract: ['n'] },
|
|
457
|
+
{ re: /^refs\/guard\/auto-backup not created yet/, key: 'detail.ref_not_created' },
|
|
458
|
+
{ re: /^(\d+) ref\(s\) found \((\d+) pre-restore snapshots?\)$/, key: 'detail.guard_refs_found', extract: ['n', 'pre'] },
|
|
459
|
+
{ re: /^no guard refs yet/, key: 'detail.guard_refs_none' },
|
|
460
|
+
{ re: /^(\d+) snapshot\(s\), (.+?) MB total$/, key: 'detail.shadow_stats', extract: ['n', 'mb'] },
|
|
461
|
+
{ re: /^\.cursor-guard-backup\/ not found/, key: 'detail.shadow_not_found' },
|
|
462
|
+
{ re: /^\.cursor-guard-backup\/ is git-ignored$/, key: 'detail.gitignore_ok' },
|
|
463
|
+
{ re: /^\.cursor-guard-backup\/ may NOT be git-ignored/, key: 'detail.gitignore_missing' },
|
|
464
|
+
{ re: /^invalid value '(.+)'$/, key: 'detail.invalid_value', extract: ['v'] },
|
|
465
|
+
{ re: /^set to 'never'/, key: 'detail.pre_restore_never' },
|
|
466
|
+
{ re: /^(\d+)s is below minimum/, key: 'detail.interval_low', extract: ['n'] },
|
|
467
|
+
{ re: /^(\d+) \/ (\d+) files matched by protect patterns$/, key: 'detail.protect_count', extract: ['matched', 'total'] },
|
|
468
|
+
{ re: /^(.+?) GB free — critically low$/, key: 'detail.disk_critical', extract: ['gb'] },
|
|
469
|
+
{ re: /^could not determine free space$/, key: 'detail.disk_unknown' },
|
|
470
|
+
{ re: /^(.+?) GB free$/, key: 'detail.disk_free', extract: ['gb'] },
|
|
471
|
+
{ re: /^lock file exists — another instance may be running\. ?(.*)$/,key: 'detail.lock_exists', extract: ['info'] },
|
|
472
|
+
{ re: /^no lock file/, key: 'detail.lock_none' },
|
|
473
|
+
{ re: /^(v\d+\.\d+\.\d+\S*) — recommended >=18$/, key: 'detail.node_old', extract: ['v'] },
|
|
474
|
+
{ re: /^server\.js found, SDK (.+)$/, key: 'detail.mcp_ok', extract: ['v'] },
|
|
475
|
+
{ re: /^server\.js found but @modelcontextprotocol/, key: 'detail.mcp_no_sdk' },
|
|
476
|
+
{ re: /^SDK installed \((.+?)\) but server\.js/, key: 'detail.mcp_no_server', extract: ['v'] },
|
|
477
|
+
{ re: /^MCP not configured/, key: 'detail.mcp_not_configured' },
|
|
478
|
+
{ re: /^running v(.+?) but disk has v(.+?) —/, key: 'detail.mcp_version_mismatch', extract: ['mem', 'disk'] },
|
|
479
|
+
{ re: /^v(\d+\.\d+\.\d+\S*)$/, key: 'detail.mcp_version_ok', extract: ['v'] },
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
function translateDetail(text) {
|
|
483
|
+
if (!text) return text;
|
|
484
|
+
for (const p of DETAIL_PATTERNS) {
|
|
485
|
+
const m = text.match(p.re);
|
|
486
|
+
if (m) {
|
|
487
|
+
const params = {};
|
|
488
|
+
if (p.extract) p.extract.forEach((k, i) => { params[k] = m[i + 1]; });
|
|
489
|
+
return t(p.key, params);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return text;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/* ── Time helpers ─────────────────────────────────────────── */
|
|
496
|
+
|
|
497
|
+
function parseShadowTs(ts) {
|
|
498
|
+
if (!ts) return null;
|
|
499
|
+
const m = String(ts).match(/^(?:pre-restore-)?(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
|
|
500
|
+
if (!m) return null;
|
|
501
|
+
return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function toDate(ts) {
|
|
505
|
+
if (!ts) return null;
|
|
506
|
+
let d = new Date(ts);
|
|
507
|
+
if (!isNaN(d.getTime())) return d;
|
|
508
|
+
d = parseShadowTs(ts);
|
|
509
|
+
return d && !isNaN(d.getTime()) ? d : null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function formatTime(ts) {
|
|
513
|
+
const d = toDate(ts);
|
|
514
|
+
if (!d) return ts || '-';
|
|
515
|
+
return new Intl.DateTimeFormat(state.locale, {
|
|
516
|
+
month: 'short', day: 'numeric',
|
|
517
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
518
|
+
}).format(d);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function relativeTime(ts) {
|
|
522
|
+
const d = toDate(ts);
|
|
523
|
+
if (!d) return '';
|
|
524
|
+
const ms = Date.now() - d.getTime();
|
|
525
|
+
if (ms < 0) return t('time.justNow');
|
|
526
|
+
const sec = Math.floor(ms / 1000);
|
|
527
|
+
if (sec < 60) return t('time.secondsAgo', { n: sec });
|
|
528
|
+
const min = Math.floor(sec / 60);
|
|
529
|
+
if (min < 60) return t('time.minutesAgo', { n: min });
|
|
530
|
+
const hr = Math.floor(min / 60);
|
|
531
|
+
if (hr < 24) return t('time.hoursAgo', { n: hr });
|
|
532
|
+
return t('time.daysAgo', { n: Math.floor(hr / 24) });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/* ── Data fetching ────────────────────────────────────────── */
|
|
536
|
+
|
|
537
|
+
async function fetchJson(url) {
|
|
538
|
+
const r = await fetch(url);
|
|
539
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
540
|
+
return r.json();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function loadProjects() {
|
|
544
|
+
state.projects = await fetchJson('/api/projects');
|
|
545
|
+
if (state.projects.length > 0 && !state.currentProjectId) {
|
|
546
|
+
state.currentProjectId = state.projects[0].id;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function loadPageData() {
|
|
551
|
+
if (!state.currentProjectId) return;
|
|
552
|
+
state.pageData = await fetchJson(`/api/page-data?id=${state.currentProjectId}`);
|
|
553
|
+
state.lastRefreshAt = Date.now();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/* ── Refresh ──────────────────────────────────────────────── */
|
|
557
|
+
|
|
558
|
+
function startRefresh() {
|
|
559
|
+
stopRefresh();
|
|
560
|
+
state.refreshTimer = setInterval(async () => {
|
|
561
|
+
try { await loadPageData(); renderAll(); } catch { /* keep existing */ }
|
|
562
|
+
}, REFRESH_MS);
|
|
563
|
+
state.tickTimer = setInterval(updateRefreshDisplay, 1000);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function stopRefresh() {
|
|
567
|
+
if (state.refreshTimer) { clearInterval(state.refreshTimer); state.refreshTimer = null; }
|
|
568
|
+
if (state.tickTimer) { clearInterval(state.tickTimer); state.tickTimer = null; }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function manualRefresh() {
|
|
572
|
+
const icon = $('#refresh-btn .icon-spin');
|
|
573
|
+
if (icon) icon.classList.add('icon-spin-active');
|
|
574
|
+
stopRefresh();
|
|
575
|
+
try { await loadPageData(); renderAll(); }
|
|
576
|
+
catch (e) { showGlobalError(e.message); }
|
|
577
|
+
if (icon) icon.classList.remove('icon-spin-active');
|
|
578
|
+
startRefresh();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function updateRefreshDisplay() {
|
|
582
|
+
const el = $('#last-refresh');
|
|
583
|
+
if (!el || !state.lastRefreshAt) return;
|
|
584
|
+
const sec = Math.floor((Date.now() - state.lastRefreshAt) / 1000);
|
|
585
|
+
el.textContent = `${t('topbar.lastRefresh')}: ${sec}s`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* ── Rendering: Top bar ───────────────────────────────────── */
|
|
589
|
+
|
|
590
|
+
function renderProjectSelect() {
|
|
591
|
+
const sel = $('#project-select');
|
|
592
|
+
sel.innerHTML = state.projects.map(p =>
|
|
593
|
+
`<option value="${esc(p.id)}" title="${esc(p.pathLabel)}">${esc(p.name)}</option>`
|
|
594
|
+
).join('');
|
|
595
|
+
if (state.currentProjectId) sel.value = state.currentProjectId;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function renderStrategyBadge(strategy) {
|
|
599
|
+
const el = $('#strategy-badge');
|
|
600
|
+
el.textContent = t('strategy.' + (strategy || 'git'));
|
|
601
|
+
el.className = 'badge badge-strategy';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/* ── Rendering: Global states ─────────────────────────────── */
|
|
605
|
+
|
|
606
|
+
function showLoading() {
|
|
607
|
+
show($('#loading-state'));
|
|
608
|
+
hide($('#error-state'));
|
|
609
|
+
$$('.screen').forEach(s => hide(s));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function showGlobalError(msg) {
|
|
613
|
+
hide($('#loading-state'));
|
|
614
|
+
show($('#error-state'));
|
|
615
|
+
$$('.screen').forEach(s => hide(s));
|
|
616
|
+
$('#error-message').textContent = msg || t('error.fetchFailed');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function showContent() {
|
|
620
|
+
hide($('#loading-state'));
|
|
621
|
+
hide($('#error-state'));
|
|
622
|
+
$$('.screen').forEach(s => show(s));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/* ── Rendering: Main dispatch ─────────────────────────────── */
|
|
626
|
+
|
|
627
|
+
function renderAll() {
|
|
628
|
+
if (!state.pageData) return;
|
|
629
|
+
const { dashboard, doctor, backups } = state.pageData;
|
|
630
|
+
|
|
631
|
+
showContent();
|
|
632
|
+
|
|
633
|
+
if (dashboard && !dashboard.error) {
|
|
634
|
+
renderStrategyBadge(dashboard.strategy);
|
|
635
|
+
renderOverview(dashboard);
|
|
636
|
+
renderBackupsSection(dashboard, Array.isArray(backups) ? backups : []);
|
|
637
|
+
renderProtection(dashboard.protectionScope);
|
|
638
|
+
} else {
|
|
639
|
+
renderSectionError('overview-grid', dashboard?.error);
|
|
640
|
+
renderSectionError('backup-stats', dashboard?.error);
|
|
641
|
+
renderSectionError('protection-content', dashboard?.error);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (doctor && !doctor.error) {
|
|
645
|
+
renderDiagnostics(doctor);
|
|
646
|
+
} else {
|
|
647
|
+
renderSectionError('diagnostics-summary', doctor?.error);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
updateStaticI18n();
|
|
651
|
+
updateRefreshDisplay();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/* ── Rendering: Overview ──────────────────────────────────── */
|
|
655
|
+
|
|
656
|
+
function renderOverview(d) {
|
|
657
|
+
renderHealthCard(d.health);
|
|
658
|
+
renderGitBackupCard(d.lastBackup);
|
|
659
|
+
renderShadowBackupCard(d.lastBackup);
|
|
660
|
+
renderWatcherCard(d.watcher);
|
|
661
|
+
renderAlertCard(d.alerts);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function renderHealthCard(health) {
|
|
665
|
+
const el = $('#card-health');
|
|
666
|
+
const st = health?.status || 'unknown';
|
|
667
|
+
const issues = health?.issues || [];
|
|
668
|
+
el.className = `card card-health`;
|
|
669
|
+
el.style.borderLeft = `3px solid var(--${st === 'healthy' ? 'green' : st === 'warning' ? 'yellow' : st === 'critical' ? 'red' : 'gray'})`;
|
|
670
|
+
el.innerHTML = `
|
|
671
|
+
<div class="card-status">
|
|
672
|
+
<span class="status-dot status-${st}"></span>
|
|
673
|
+
<span class="status-text status-${st}">${t('health.' + st)}</span>
|
|
674
|
+
</div>
|
|
675
|
+
${issues.length > 0 ? `<ul class="issue-list">${issues.map(i => `<li class="text-sm">${esc(translateIssue(i))}</li>`).join('')}</ul>` : ''}
|
|
676
|
+
`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function renderGitBackupCard(lastBackup) {
|
|
680
|
+
const el = $('#card-git-backup');
|
|
681
|
+
const g = lastBackup?.git;
|
|
682
|
+
if (!g) {
|
|
683
|
+
el.innerHTML = `<div class="card-label">${t('gitBackup.title')}</div><div class="card-empty">${t('gitBackup.none')}</div>`;
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
el.innerHTML = `
|
|
687
|
+
<div class="card-label">${t('gitBackup.title')}</div>
|
|
688
|
+
<div class="card-value">${esc(relativeTime(g.timestamp))}</div>
|
|
689
|
+
<div class="card-detail text-muted">
|
|
690
|
+
<span class="text-mono">${esc(g.shortHash)}</span> · <span class="text-sm">${esc(formatTime(g.timestamp))}</span>
|
|
691
|
+
</div>
|
|
692
|
+
`;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function renderShadowBackupCard(lastBackup) {
|
|
696
|
+
const el = $('#card-shadow-backup');
|
|
697
|
+
const s = lastBackup?.shadow;
|
|
698
|
+
if (!s) {
|
|
699
|
+
el.innerHTML = `<div class="card-label">${t('shadowBackup.title')}</div><div class="card-empty">${t('shadowBackup.none')}</div>`;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
el.innerHTML = `
|
|
703
|
+
<div class="card-label">${t('shadowBackup.title')}</div>
|
|
704
|
+
<div class="card-value">${esc(relativeTime(s.timestamp))}</div>
|
|
705
|
+
<div class="card-detail text-muted text-sm">${esc(formatTime(s.timestamp))}</div>
|
|
706
|
+
`;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function renderWatcherCard(watcher) {
|
|
710
|
+
const el = $('#card-watcher');
|
|
711
|
+
let st = 'stopped';
|
|
712
|
+
if (watcher?.running) st = 'running';
|
|
713
|
+
else if (watcher?.stale) st = 'stale';
|
|
714
|
+
el.innerHTML = `
|
|
715
|
+
<div class="card-label">${t('watcher.title')}</div>
|
|
716
|
+
<div class="card-status">
|
|
717
|
+
<span class="status-dot status-${st}"></span>
|
|
718
|
+
<span>${t('watcher.' + st)}</span>
|
|
719
|
+
</div>
|
|
720
|
+
${watcher?.pid ? `<div class="card-detail text-muted text-sm">${t('watcher.pid')}: ${watcher.pid}</div>` : ''}
|
|
721
|
+
${watcher?.startedAt ? `<div class="card-detail text-muted text-sm">${t('watcher.since')}: ${esc(formatTime(watcher.startedAt))}</div>` : ''}
|
|
722
|
+
`;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function renderAlertCard(alerts) {
|
|
726
|
+
const el = $('#card-alert');
|
|
727
|
+
if (!alerts?.active) {
|
|
728
|
+
el.innerHTML = `
|
|
729
|
+
<div class="card-label">${t('alert.title')}</div>
|
|
730
|
+
<div class="card-status"><span class="status-dot status-healthy"></span><span>${t('alert.none')}</span></div>
|
|
731
|
+
`;
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const a = alerts.latest || {};
|
|
735
|
+
el.innerHTML = `
|
|
736
|
+
<div class="card-label">${t('alert.title')}</div>
|
|
737
|
+
<div class="card-status"><span class="status-dot status-warning"></span><span class="status-text status-warning">${t('alert.active')}</span></div>
|
|
738
|
+
<div class="card-detail text-muted text-sm">${esc(translateIssue(a.recommendation || a.message || ''))}</div>
|
|
739
|
+
`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/* ── Rendering: Backups Section ───────────────────────────── */
|
|
743
|
+
|
|
744
|
+
function renderBackupsSection(dashboard, backups) {
|
|
745
|
+
renderBackupStats(dashboard, backups);
|
|
746
|
+
renderFilterBar();
|
|
747
|
+
renderBackupTable(backups);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function renderBackupStats(d, backups) {
|
|
751
|
+
const gitCount = d.counts?.git?.commits || 0;
|
|
752
|
+
const shadowCount = d.counts?.shadow?.snapshots || 0;
|
|
753
|
+
const preRestoreCount = Array.isArray(backups)
|
|
754
|
+
? backups.filter(b => b.type === 'git-pre-restore' || b.type === 'shadow-pre-restore').length
|
|
755
|
+
: 0;
|
|
756
|
+
const gitDisk = d.diskUsage?.git?.display || '0B';
|
|
757
|
+
const shadowDisk = d.diskUsage?.shadow?.display || '0B';
|
|
758
|
+
|
|
759
|
+
$('#backup-stats').innerHTML = `
|
|
760
|
+
<div class="stat-card"><div class="stat-label">${t('backups.gitCommits')}</div><div class="stat-value">${gitCount}</div></div>
|
|
761
|
+
<div class="stat-card"><div class="stat-label">${t('backups.shadowSnapshots')}</div><div class="stat-value">${shadowCount}</div></div>
|
|
762
|
+
<div class="stat-card"><div class="stat-label">${t('backups.preRestore')}</div><div class="stat-value">${preRestoreCount}</div></div>
|
|
763
|
+
<div class="stat-card"><div class="stat-label">${t('backups.gitDisk')}</div><div class="stat-value">${esc(gitDisk)}</div></div>
|
|
764
|
+
<div class="stat-card"><div class="stat-label">${t('backups.shadowDisk')}</div><div class="stat-value">${esc(shadowDisk)}</div></div>
|
|
765
|
+
`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function renderFilterBar() {
|
|
769
|
+
const types = [
|
|
770
|
+
{ key: 'all', label: 'backups.filterAll' },
|
|
771
|
+
{ key: 'git-auto-backup', label: 'type.git-auto-backup' },
|
|
772
|
+
{ key: 'git-pre-restore', label: 'type.git-pre-restore' },
|
|
773
|
+
{ key: 'shadow', label: 'type.shadow' },
|
|
774
|
+
{ key: 'shadow-pre-restore',label: 'type.shadow-pre-restore' },
|
|
775
|
+
];
|
|
776
|
+
$('#backup-filters').innerHTML = types.map(t2 =>
|
|
777
|
+
`<button class="filter-btn ${state.backupFilter === t2.key ? 'active' : ''}" data-filter="${t2.key}">${t(t2.label)}</button>`
|
|
778
|
+
).join('');
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function renderBackupTable(backups) {
|
|
782
|
+
if (!Array.isArray(backups)) {
|
|
783
|
+
$('#backup-table-wrap').innerHTML = `<div class="error-panel">${t('error.sectionFailed')}</div>`;
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
state.filteredBackups = state.backupFilter === 'all'
|
|
787
|
+
? backups
|
|
788
|
+
: backups.filter(b => b.type === state.backupFilter);
|
|
789
|
+
|
|
790
|
+
if (state.filteredBackups.length === 0) {
|
|
791
|
+
$('#backup-table-wrap').innerHTML = `<div class="empty-state">${t('backups.noBackups')}</div>`;
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const rows = state.filteredBackups.map((b, i) => {
|
|
796
|
+
const badgeClass = b.type.startsWith('git') ? (b.type.includes('pre') ? 'badge-pre' : 'badge-git') : (b.type.includes('pre') ? 'badge-pre' : 'badge-shadow');
|
|
797
|
+
return `<tr data-bi="${i}">
|
|
798
|
+
<td><div>${esc(formatTime(b.timestamp))}</div><div class="text-muted text-sm">${esc(relativeTime(b.timestamp))}</div></td>
|
|
799
|
+
<td><span class="badge ${badgeClass}">${t('type.' + b.type)}</span></td>
|
|
800
|
+
<td class="text-mono">${esc(b.shortHash || b.timestamp || '-')}</td>
|
|
801
|
+
</tr>`;
|
|
802
|
+
}).join('');
|
|
803
|
+
|
|
804
|
+
$('#backup-table-wrap').innerHTML = `
|
|
805
|
+
<table class="data-table">
|
|
806
|
+
<thead><tr>
|
|
807
|
+
<th>${t('backups.col.time')}</th>
|
|
808
|
+
<th>${t('backups.col.type')}</th>
|
|
809
|
+
<th>${t('backups.col.ref')}</th>
|
|
810
|
+
</tr></thead>
|
|
811
|
+
<tbody>${rows}</tbody>
|
|
812
|
+
</table>
|
|
813
|
+
`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/* ── Rendering: Protection Scope ──────────────────────────── */
|
|
817
|
+
|
|
818
|
+
function renderProtection(scope) {
|
|
819
|
+
const el = $('#protection-content');
|
|
820
|
+
if (!scope) { el.innerHTML = `<div class="empty-state">${t('empty.noData')}</div>`; return; }
|
|
821
|
+
|
|
822
|
+
const protectList = scope.protect || [];
|
|
823
|
+
const ignoreList = scope.ignore || [];
|
|
824
|
+
const isAll = protectList.length === 1 && protectList[0] === '**';
|
|
825
|
+
|
|
826
|
+
el.innerHTML = `
|
|
827
|
+
<div class="protection-grid">
|
|
828
|
+
<div class="pattern-card">
|
|
829
|
+
<h4>${t('protection.protect')}</h4>
|
|
830
|
+
${isAll
|
|
831
|
+
? `<p class="text-muted text-sm">${t('protection.allFiles')}</p>`
|
|
832
|
+
: `<ul class="pattern-list">${protectList.map(p => `<li class="pattern-item">${esc(p)}</li>`).join('')}</ul>`
|
|
833
|
+
}
|
|
834
|
+
</div>
|
|
835
|
+
<div class="pattern-card">
|
|
836
|
+
<h4>${t('protection.ignore')}</h4>
|
|
837
|
+
${ignoreList.length === 0
|
|
838
|
+
? `<p class="text-muted text-sm">${t('protection.noIgnore')}</p>`
|
|
839
|
+
: `<ul class="pattern-list">${ignoreList.map(p => `<li class="pattern-item">${esc(p)}</li>`).join('')}</ul>`
|
|
840
|
+
}
|
|
841
|
+
</div>
|
|
842
|
+
</div>
|
|
843
|
+
<div class="protection-count">${t('protection.fileCount', { n: scope.fileCount || 0 })}</div>
|
|
844
|
+
<p class="protection-note">${t('protection.note')}</p>
|
|
845
|
+
`;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/* ── Rendering: Diagnostics ───────────────────────────────── */
|
|
849
|
+
|
|
850
|
+
function renderDiagnostics(doctor) {
|
|
851
|
+
const el = $('#diagnostics-summary');
|
|
852
|
+
const s = doctor.summary || { pass: 0, warn: 0, fail: 0 };
|
|
853
|
+
|
|
854
|
+
el.innerHTML = `
|
|
855
|
+
<div class="diag-summary" id="diag-summary-click">
|
|
856
|
+
<div class="diag-counts">
|
|
857
|
+
<div class="diag-count"><span class="num" style="color:var(--green)">${s.pass}</span><span class="label badge-pass">${t('diagnostics.pass')}</span></div>
|
|
858
|
+
<div class="diag-count"><span class="num" style="color:var(--yellow)">${s.warn}</span><span class="label badge-warn">${t('diagnostics.warn')}</span></div>
|
|
859
|
+
<div class="diag-count"><span class="num" style="color:var(--red)">${s.fail}</span><span class="label badge-fail">${t('diagnostics.fail')}</span></div>
|
|
860
|
+
</div>
|
|
861
|
+
<span class="diag-hint">${t('diagnostics.hint')}</span>
|
|
862
|
+
</div>
|
|
863
|
+
`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/* ── Rendering: Error / Empty ─────────────────────────────── */
|
|
867
|
+
|
|
868
|
+
function renderSectionError(elementId, msg) {
|
|
869
|
+
const el = $(`#${elementId}`);
|
|
870
|
+
if (!el) return;
|
|
871
|
+
el.innerHTML = `<div class="error-panel"><div class="error-icon">⚠</div><p>${esc(msg || t('error.sectionFailed'))}</p></div>`;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/* ── Drawers ──────────────────────────────────────────────── */
|
|
875
|
+
|
|
876
|
+
function openDrawer(name) {
|
|
877
|
+
state.drawerOpen = name;
|
|
878
|
+
$(`#${name}-drawer`).classList.add('active');
|
|
879
|
+
$('#drawer-overlay').classList.add('active');
|
|
880
|
+
document.body.style.overflow = 'hidden';
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function closeDrawer() {
|
|
884
|
+
if (!state.drawerOpen) return;
|
|
885
|
+
$(`#${state.drawerOpen}-drawer`).classList.remove('active');
|
|
886
|
+
$('#drawer-overlay').classList.remove('active');
|
|
887
|
+
document.body.style.overflow = '';
|
|
888
|
+
state.drawerOpen = null;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function openRestoreDrawer(backup) {
|
|
892
|
+
const body = $('#restore-drawer-body');
|
|
893
|
+
const fields = [
|
|
894
|
+
{ key: 'drawer.field.time', val: formatTime(backup.timestamp) },
|
|
895
|
+
{ key: 'drawer.field.type', val: t('type.' + backup.type) },
|
|
896
|
+
];
|
|
897
|
+
if (backup.ref) fields.push({ key: 'drawer.field.ref', val: backup.ref });
|
|
898
|
+
if (backup.commitHash) fields.push({ key: 'drawer.field.hash', val: backup.commitHash });
|
|
899
|
+
if (backup.path) fields.push({ key: 'drawer.field.path', val: backup.path });
|
|
900
|
+
if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
|
|
901
|
+
|
|
902
|
+
const refText = backup.ref || backup.shortHash || backup.timestamp || '';
|
|
903
|
+
const jsonText = JSON.stringify(backup, null, 2);
|
|
904
|
+
|
|
905
|
+
body.innerHTML = `
|
|
906
|
+
${fields.map(f => `
|
|
907
|
+
<div class="restore-field">
|
|
908
|
+
<div class="restore-field-label">${t(f.key)}</div>
|
|
909
|
+
<div class="restore-field-value text-mono">${esc(f.val)}</div>
|
|
910
|
+
</div>
|
|
911
|
+
`).join('')}
|
|
912
|
+
<div class="restore-actions">
|
|
913
|
+
<button class="btn btn-sm" data-copy="${esc(refText)}">${t('drawer.copyRef')}</button>
|
|
914
|
+
<button class="btn btn-sm" data-copy-json>${t('drawer.copyJson')}</button>
|
|
915
|
+
<button class="btn btn-sm" id="preview-toggle">${t('drawer.preview')}</button>
|
|
916
|
+
</div>
|
|
917
|
+
<div id="json-preview-wrap" class="hidden">
|
|
918
|
+
<pre class="json-preview">${esc(jsonText)}</pre>
|
|
919
|
+
</div>
|
|
920
|
+
`;
|
|
921
|
+
|
|
922
|
+
body.querySelector('[data-copy-json]')?.addEventListener('click', () => copyText(jsonText));
|
|
923
|
+
body.querySelector('#preview-toggle')?.addEventListener('click', () => {
|
|
924
|
+
const wrap = body.querySelector('#json-preview-wrap');
|
|
925
|
+
wrap.classList.toggle('hidden');
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
openDrawer('restore');
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function openDoctorDrawer() {
|
|
932
|
+
const doctor = state.pageData?.doctor;
|
|
933
|
+
if (!doctor || doctor.error) return;
|
|
934
|
+
const body = $('#doctor-drawer-body');
|
|
935
|
+
|
|
936
|
+
body.innerHTML = (doctor.checks || []).map(c => {
|
|
937
|
+
const badgeClass = c.status === 'PASS' ? 'badge-pass' : c.status === 'WARN' ? 'badge-warn' : 'badge-fail';
|
|
938
|
+
const shouldOpen = c.status !== 'PASS';
|
|
939
|
+
return `
|
|
940
|
+
<details class="check-item" ${shouldOpen ? 'open' : ''}>
|
|
941
|
+
<summary>
|
|
942
|
+
<span class="badge ${badgeClass}">${t('diagnostics.' + c.status)}</span>
|
|
943
|
+
<span class="check-name">${esc(translateCheckName(c.name))}</span>
|
|
944
|
+
</summary>
|
|
945
|
+
${c.detail ? `<div class="check-detail">${esc(translateDetail(c.detail))}</div>` : ''}
|
|
946
|
+
</details>
|
|
947
|
+
`;
|
|
948
|
+
}).join('');
|
|
949
|
+
|
|
950
|
+
openDrawer('doctor');
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/* ── Copy to clipboard ────────────────────────────────────── */
|
|
954
|
+
|
|
955
|
+
async function copyText(text) {
|
|
956
|
+
try {
|
|
957
|
+
await navigator.clipboard.writeText(text);
|
|
958
|
+
} catch {
|
|
959
|
+
const ta = document.createElement('textarea');
|
|
960
|
+
ta.value = text;
|
|
961
|
+
ta.style.cssText = 'position:fixed;left:-9999px';
|
|
962
|
+
document.body.appendChild(ta);
|
|
963
|
+
ta.select();
|
|
964
|
+
document.execCommand('copy');
|
|
965
|
+
document.body.removeChild(ta);
|
|
966
|
+
}
|
|
967
|
+
showToast(t('drawer.copied'));
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function showToast(msg) {
|
|
971
|
+
let toast = $('#copy-toast');
|
|
972
|
+
if (!toast) {
|
|
973
|
+
toast = document.createElement('div');
|
|
974
|
+
toast.id = 'copy-toast';
|
|
975
|
+
toast.className = 'copy-toast';
|
|
976
|
+
document.body.appendChild(toast);
|
|
977
|
+
}
|
|
978
|
+
toast.textContent = msg;
|
|
979
|
+
toast.classList.add('show');
|
|
980
|
+
setTimeout(() => toast.classList.remove('show'), 1500);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/* ── Event Listeners ──────────────────────────────────────── */
|
|
984
|
+
|
|
985
|
+
function setupEvents() {
|
|
986
|
+
$('#project-select').addEventListener('change', async (e) => {
|
|
987
|
+
state.currentProjectId = e.target.value;
|
|
988
|
+
state.backupFilter = 'all';
|
|
989
|
+
stopRefresh();
|
|
990
|
+
showLoading();
|
|
991
|
+
try { await loadPageData(); renderAll(); }
|
|
992
|
+
catch (err) { showGlobalError(err.message); }
|
|
993
|
+
startRefresh();
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
$('#refresh-btn').addEventListener('click', manualRefresh);
|
|
997
|
+
$('#error-retry').addEventListener('click', manualRefresh);
|
|
998
|
+
|
|
999
|
+
$('#lang-toggle').addEventListener('click', () => {
|
|
1000
|
+
setLocale(state.locale === 'zh-CN' ? 'en-US' : 'zh-CN');
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
// Filter buttons (event delegation)
|
|
1004
|
+
$('#backup-filters').addEventListener('click', (e) => {
|
|
1005
|
+
const btn = e.target.closest('.filter-btn');
|
|
1006
|
+
if (!btn) return;
|
|
1007
|
+
state.backupFilter = btn.dataset.filter;
|
|
1008
|
+
const backups = state.pageData?.backups;
|
|
1009
|
+
if (Array.isArray(backups)) {
|
|
1010
|
+
renderFilterBar();
|
|
1011
|
+
renderBackupTable(backups);
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
// Backup table row click (event delegation)
|
|
1016
|
+
$('#backup-table-wrap').addEventListener('click', (e) => {
|
|
1017
|
+
const row = e.target.closest('tr[data-bi]');
|
|
1018
|
+
if (!row) return;
|
|
1019
|
+
const idx = parseInt(row.dataset.bi, 10);
|
|
1020
|
+
const backup = state.filteredBackups[idx];
|
|
1021
|
+
if (backup) openRestoreDrawer(backup);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
// Diagnostics summary click
|
|
1025
|
+
$('#diagnostics-summary').addEventListener('click', (e) => {
|
|
1026
|
+
if (e.target.closest('#diag-summary-click')) openDoctorDrawer();
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// Copy ref buttons (event delegation on drawers)
|
|
1030
|
+
document.addEventListener('click', (e) => {
|
|
1031
|
+
const copyBtn = e.target.closest('[data-copy]');
|
|
1032
|
+
if (copyBtn) copyText(copyBtn.dataset.copy);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
// Close drawer
|
|
1036
|
+
$('#drawer-overlay').addEventListener('click', closeDrawer);
|
|
1037
|
+
document.querySelectorAll('[data-action="close-drawer"]').forEach(btn => {
|
|
1038
|
+
btn.addEventListener('click', closeDrawer);
|
|
1039
|
+
});
|
|
1040
|
+
document.addEventListener('keydown', (e) => {
|
|
1041
|
+
if (e.key === 'Escape') closeDrawer();
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/* ── Init ─────────────────────────────────────────────────── */
|
|
1046
|
+
|
|
1047
|
+
async function init() {
|
|
1048
|
+
state.locale = detectLocale();
|
|
1049
|
+
document.documentElement.lang = state.locale === 'zh-CN' ? 'zh-CN' : 'en';
|
|
1050
|
+
document.title = t('app.title');
|
|
1051
|
+
updateStaticI18n();
|
|
1052
|
+
showLoading();
|
|
1053
|
+
|
|
1054
|
+
try {
|
|
1055
|
+
await loadProjects();
|
|
1056
|
+
renderProjectSelect();
|
|
1057
|
+
await loadPageData();
|
|
1058
|
+
renderAll();
|
|
1059
|
+
startRefresh();
|
|
1060
|
+
} catch (e) {
|
|
1061
|
+
showGlobalError(e.message);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1066
|
+
setupEvents();
|
|
1067
|
+
init();
|
|
1068
|
+
});
|