openclaw-config-guardian 1.0.2 → 1.0.3

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 CHANGED
@@ -194,6 +194,9 @@ MIT License - see [LICENSE](LICENSE) file for details.
194
194
 
195
195
  ## 📝 Changelog
196
196
 
197
+ ### v1.0.3 (2026-05-09)
198
+ - ✅ Fixed: Web UI static files not included in npm package
199
+
197
200
  ### v1.0.2 (2026-05-09)
198
201
  - ✅ Added: Detailed rollback status (config restored + Gateway start status)
199
202
  - ✅ Added: Clear error messages when Gateway fails to start after rollback
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-config-guardian",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "OpenClaw Config Guardian - Auto backup & one-click rollback for OpenClaw configuration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,6 +14,7 @@
14
14
  "bin",
15
15
  "scripts",
16
16
  "skills",
17
+ "webui",
17
18
  "openclaw.plugin.json",
18
19
  "README.md",
19
20
  "LICENSE"
@@ -0,0 +1,600 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>OpenClaw Config Guardian</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ min-height: 100vh;
18
+ padding: 20px;
19
+ }
20
+
21
+ .container {
22
+ max-width: 1200px;
23
+ margin: 0 auto;
24
+ }
25
+
26
+ header {
27
+ text-align: center;
28
+ color: white;
29
+ margin-bottom: 30px;
30
+ }
31
+
32
+ header h1 {
33
+ font-size: 2.5rem;
34
+ margin-bottom: 10px;
35
+ }
36
+
37
+ header p {
38
+ opacity: 0.9;
39
+ font-size: 1.1rem;
40
+ }
41
+
42
+ .card {
43
+ background: white;
44
+ border-radius: 12px;
45
+ padding: 24px;
46
+ margin-bottom: 20px;
47
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
48
+ }
49
+
50
+ .card h2 {
51
+ color: #333;
52
+ margin-bottom: 20px;
53
+ font-size: 1.3rem;
54
+ }
55
+
56
+ .status-grid {
57
+ display: grid;
58
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
59
+ gap: 15px;
60
+ }
61
+
62
+ .status-item {
63
+ background: #f8f9fa;
64
+ padding: 15px;
65
+ border-radius: 8px;
66
+ text-align: center;
67
+ }
68
+
69
+ .status-item .label {
70
+ color: #666;
71
+ font-size: 0.9rem;
72
+ margin-bottom: 5px;
73
+ }
74
+
75
+ .status-item .value {
76
+ font-size: 1.2rem;
77
+ font-weight: bold;
78
+ color: #333;
79
+ }
80
+
81
+ .status-item .value.running {
82
+ color: #28a745;
83
+ }
84
+
85
+ .status-item .value.stopped {
86
+ color: #dc3545;
87
+ }
88
+
89
+ .btn {
90
+ display: inline-block;
91
+ padding: 10px 20px;
92
+ border: none;
93
+ border-radius: 6px;
94
+ cursor: pointer;
95
+ font-size: 1rem;
96
+ transition: all 0.3s;
97
+ margin-right: 10px;
98
+ margin-bottom: 10px;
99
+ }
100
+
101
+ .btn-primary {
102
+ background: #667eea;
103
+ color: white;
104
+ }
105
+
106
+ .btn-primary:hover {
107
+ background: #5a6fd6;
108
+ }
109
+
110
+ .btn-success {
111
+ background: #28a745;
112
+ color: white;
113
+ }
114
+
115
+ .btn-success:hover {
116
+ background: #218838;
117
+ }
118
+
119
+ .btn-danger {
120
+ background: #dc3545;
121
+ color: white;
122
+ }
123
+
124
+ .btn-danger:hover {
125
+ background: #c82333;
126
+ }
127
+
128
+ .btn:disabled {
129
+ opacity: 0.6;
130
+ cursor: not-allowed;
131
+ }
132
+
133
+ .toggle-switch {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 10px;
137
+ margin-bottom: 20px;
138
+ }
139
+
140
+ .switch {
141
+ position: relative;
142
+ display: inline-block;
143
+ width: 60px;
144
+ height: 34px;
145
+ }
146
+
147
+ .switch input {
148
+ opacity: 0;
149
+ width: 0;
150
+ height: 0;
151
+ }
152
+
153
+ .slider {
154
+ position: absolute;
155
+ cursor: pointer;
156
+ top: 0;
157
+ left: 0;
158
+ right: 0;
159
+ bottom: 0;
160
+ background-color: #ccc;
161
+ transition: .4s;
162
+ border-radius: 34px;
163
+ }
164
+
165
+ .slider:before {
166
+ position: absolute;
167
+ content: "";
168
+ height: 26px;
169
+ width: 26px;
170
+ left: 4px;
171
+ bottom: 4px;
172
+ background-color: white;
173
+ transition: .4s;
174
+ border-radius: 50%;
175
+ }
176
+
177
+ input:checked + .slider {
178
+ background-color: #28a745;
179
+ }
180
+
181
+ input:checked + .slider:before {
182
+ transform: translateX(26px);
183
+ }
184
+
185
+ .backup-list {
186
+ max-height: 400px;
187
+ overflow-y: auto;
188
+ }
189
+
190
+ .backup-item {
191
+ display: flex;
192
+ justify-content: space-between;
193
+ align-items: center;
194
+ padding: 15px;
195
+ background: #f8f9fa;
196
+ border-radius: 8px;
197
+ margin-bottom: 10px;
198
+ }
199
+
200
+ .backup-item:hover {
201
+ background: #e9ecef;
202
+ }
203
+
204
+ .backup-info h4 {
205
+ color: #333;
206
+ margin-bottom: 5px;
207
+ }
208
+
209
+ .backup-info p {
210
+ color: #666;
211
+ font-size: 0.9rem;
212
+ }
213
+
214
+ .backup-actions {
215
+ display: flex;
216
+ gap: 10px;
217
+ }
218
+
219
+ .btn-small {
220
+ padding: 6px 12px;
221
+ font-size: 0.85rem;
222
+ }
223
+
224
+ .message {
225
+ position: fixed;
226
+ top: 20px;
227
+ right: 20px;
228
+ padding: 15px 20px;
229
+ border-radius: 8px;
230
+ color: white;
231
+ font-weight: bold;
232
+ z-index: 1000;
233
+ animation: slideIn 0.3s ease;
234
+ }
235
+
236
+ @keyframes slideIn {
237
+ from {
238
+ transform: translateX(100%);
239
+ opacity: 0;
240
+ }
241
+ to {
242
+ transform: translateX(0);
243
+ opacity: 1;
244
+ }
245
+ }
246
+
247
+ .message.success {
248
+ background: #28a745;
249
+ }
250
+
251
+ .message.error {
252
+ background: #dc3545;
253
+ }
254
+
255
+ .message.info {
256
+ background: #17a2b8;
257
+ }
258
+
259
+ .empty-state {
260
+ text-align: center;
261
+ padding: 40px;
262
+ color: #666;
263
+ }
264
+
265
+ .empty-state svg {
266
+ width: 64px;
267
+ height: 64px;
268
+ margin-bottom: 15px;
269
+ opacity: 0.5;
270
+ }
271
+
272
+ .loading {
273
+ display: inline-block;
274
+ width: 20px;
275
+ height: 20px;
276
+ border: 3px solid #f3f3f3;
277
+ border-top: 3px solid #667eea;
278
+ border-radius: 50%;
279
+ animation: spin 1s linear infinite;
280
+ }
281
+
282
+ @keyframes spin {
283
+ 0% { transform: rotate(0deg); }
284
+ 100% { transform: rotate(360deg); }
285
+ }
286
+
287
+ .modal {
288
+ display: none;
289
+ position: fixed;
290
+ top: 0;
291
+ left: 0;
292
+ width: 100%;
293
+ height: 100%;
294
+ background: rgba(0, 0, 0, 0.5);
295
+ z-index: 2000;
296
+ justify-content: center;
297
+ align-items: center;
298
+ }
299
+
300
+ .modal.active {
301
+ display: flex;
302
+ }
303
+
304
+ .modal-content {
305
+ background: white;
306
+ padding: 30px;
307
+ border-radius: 12px;
308
+ max-width: 400px;
309
+ text-align: center;
310
+ }
311
+
312
+ .modal-content h3 {
313
+ margin-bottom: 15px;
314
+ color: #333;
315
+ }
316
+
317
+ .modal-content p {
318
+ color: #666;
319
+ margin-bottom: 20px;
320
+ }
321
+
322
+ .modal-actions {
323
+ display: flex;
324
+ gap: 10px;
325
+ justify-content: center;
326
+ }
327
+ </style>
328
+ </head>
329
+ <body>
330
+ <div class="container">
331
+ <header>
332
+ <h1>🛡️ Config Guardian</h1>
333
+ <p>OpenClaw 配置守护者 - 自动备份与一键回滚</p>
334
+ </header>
335
+
336
+ <div class="card">
337
+ <h2>📊 系统状态</h2>
338
+ <div class="status-grid">
339
+ <div class="status-item">
340
+ <div class="label">OpenClaw 状态</div>
341
+ <div class="value" id="gateway-status">检测中...</div>
342
+ </div>
343
+ <div class="status-item">
344
+ <div class="label">自动备份</div>
345
+ <div class="value" id="auto-backup">检测中...</div>
346
+ </div>
347
+ <div class="status-item">
348
+ <div class="label">备份数量</div>
349
+ <div class="value" id="backup-count">-</div>
350
+ </div>
351
+ <div class="status-item">
352
+ <div class="label">上次备份</div>
353
+ <div class="value" id="last-backup">-</div>
354
+ </div>
355
+ </div>
356
+ </div>
357
+
358
+ <div class="card">
359
+ <h2>⚙️ 自动备份设置</h2>
360
+ <div class="toggle-switch">
361
+ <label class="switch">
362
+ <input type="checkbox" id="auto-backup-toggle">
363
+ <span class="slider"></span>
364
+ </label>
365
+ <span id="auto-backup-text">自动备份(每天凌晨 2 点)</span>
366
+ </div>
367
+ <button class="btn btn-success" id="backup-now-btn" onclick="createBackup()">
368
+ 💾 立即备份
369
+ </button>
370
+ </div>
371
+
372
+ <div class="card">
373
+ <h2>📦 备份列表</h2>
374
+ <div class="backup-list" id="backup-list">
375
+ <div class="loading"></div>
376
+ </div>
377
+ </div>
378
+ </div>
379
+
380
+ <!-- 确认对话框 -->
381
+ <div class="modal" id="confirm-modal">
382
+ <div class="modal-content">
383
+ <h3>⚠️ 确认回滚</h3>
384
+ <p id="confirm-message">确定要回滚到这个备份吗?当前配置将被覆盖。</p>
385
+ <div class="modal-actions">
386
+ <button class="btn btn-danger" id="confirm-btn">确认回滚</button>
387
+ <button class="btn" onclick="closeModal()">取消</button>
388
+ </div>
389
+ </div>
390
+ </div>
391
+
392
+ <script>
393
+ let currentBackups = [];
394
+ let selectedBackupId = null;
395
+
396
+ // 显示消息
397
+ function showMessage(text, type = 'info') {
398
+ const msg = document.createElement('div');
399
+ msg.className = `message ${type}`;
400
+ msg.textContent = text;
401
+ document.body.appendChild(msg);
402
+ setTimeout(() => msg.remove(), 3000);
403
+ }
404
+
405
+ // 获取状态
406
+ async function loadStatus() {
407
+ try {
408
+ const res = await fetch('/api/status');
409
+ const data = await res.json();
410
+
411
+ const gatewayStatus = document.getElementById('gateway-status');
412
+ gatewayStatus.textContent = data.gateway === 'running' ? '运行中' : '已停止';
413
+ gatewayStatus.className = 'value ' + data.gateway;
414
+
415
+ const autoBackup = document.getElementById('auto-backup');
416
+ autoBackup.textContent = data.config.autoBackup ? '已开启' : '已关闭';
417
+
418
+ const toggle = document.getElementById('auto-backup-toggle');
419
+ toggle.checked = data.config.autoBackup;
420
+
421
+ document.getElementById('backup-count').textContent = currentBackups.length;
422
+ document.getElementById('last-backup').textContent =
423
+ data.config.lastBackup ? new Date(data.config.lastBackup).toLocaleString() : '无';
424
+ } catch (err) {
425
+ console.error('加载状态失败:', err);
426
+ }
427
+ }
428
+
429
+ // 获取备份列表
430
+ async function loadBackups() {
431
+ try {
432
+ const res = await fetch('/api/backups');
433
+ currentBackups = await res.json();
434
+ renderBackups();
435
+ loadStatus();
436
+ } catch (err) {
437
+ console.error('加载备份失败:', err);
438
+ document.getElementById('backup-list').innerHTML =
439
+ '<div class="empty-state">加载失败,请刷新页面重试</div>';
440
+ }
441
+ }
442
+
443
+ // 渲染备份列表
444
+ function renderBackups() {
445
+ const container = document.getElementById('backup-list');
446
+
447
+ if (currentBackups.length === 0) {
448
+ container.innerHTML = `
449
+ <div class="empty-state">
450
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
451
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
452
+ </svg>
453
+ <p>暂无备份</p>
454
+ <p style="font-size: 0.9rem; margin-top: 10px;">点击"立即备份"创建第一个备份</p>
455
+ </div>
456
+ `;
457
+ return;
458
+ }
459
+
460
+ container.innerHTML = currentBackups.map(backup => `
461
+ <div class="backup-item">
462
+ <div class="backup-info">
463
+ <h4>${backup.id}</h4>
464
+ <p>时间: ${new Date(backup.timestamp).toLocaleString()}</p>
465
+ <p>项目: ${backup.items.join(', ')}</p>
466
+ </div>
467
+ <div class="backup-actions">
468
+ <button class="btn btn-primary btn-small" onclick="confirmRestore('${backup.id}')">
469
+ 🔄 回滚
470
+ </button>
471
+ <button class="btn btn-danger btn-small" onclick="deleteBackup('${backup.id}')">
472
+ 🗑️ 删除
473
+ </button>
474
+ </div>
475
+ </div>
476
+ `).join('');
477
+ }
478
+
479
+ // 创建备份
480
+ async function createBackup() {
481
+ const btn = document.getElementById('backup-now-btn');
482
+ btn.disabled = true;
483
+ btn.innerHTML = '<span class="loading"></span> 备份中...';
484
+
485
+ try {
486
+ const res = await fetch('/api/backup', { method: 'POST' });
487
+ const data = await res.json();
488
+
489
+ if (data.success) {
490
+ showMessage('✅ 备份成功!', 'success');
491
+ await loadBackups();
492
+ } else {
493
+ showMessage('❌ 备份失败: ' + (data.error || '未知错误'), 'error');
494
+ }
495
+ } catch (err) {
496
+ showMessage('❌ 备份失败: ' + err.message, 'error');
497
+ } finally {
498
+ btn.disabled = false;
499
+ btn.innerHTML = '💾 立即备份';
500
+ }
501
+ }
502
+
503
+ // 确认回滚
504
+ function confirmRestore(backupId) {
505
+ selectedBackupId = backupId;
506
+ document.getElementById('confirm-message').textContent =
507
+ `确定要回滚到 "${backupId}" 吗?当前配置将被覆盖。`;
508
+ document.getElementById('confirm-modal').classList.add('active');
509
+
510
+ document.getElementById('confirm-btn').onclick = () => {
511
+ closeModal();
512
+ restoreBackup(backupId);
513
+ };
514
+ }
515
+
516
+ // 关闭对话框
517
+ function closeModal() {
518
+ document.getElementById('confirm-modal').classList.remove('active');
519
+ selectedBackupId = null;
520
+ }
521
+
522
+ // 回滚备份
523
+ async function restoreBackup(backupId) {
524
+ showMessage('🔄 正在回滚...', 'info');
525
+
526
+ try {
527
+ const res = await fetch('/api/restore', {
528
+ method: 'POST',
529
+ headers: { 'Content-Type': 'application/json' },
530
+ body: JSON.stringify({ id: backupId })
531
+ });
532
+ const data = await res.json();
533
+
534
+ if (data.success) {
535
+ if (data.gatewayStarted) {
536
+ showMessage('✅ ' + (data.message || '回滚成功!Gateway 已启动'), 'success');
537
+ } else {
538
+ showMessage('⚠️ ' + (data.message || '配置已恢复,但 Gateway 启动失败'), 'error');
539
+ if (data.gatewayError) {
540
+ console.error('Gateway 启动错误:', data.gatewayError);
541
+ }
542
+ }
543
+ await loadStatus();
544
+ } else {
545
+ showMessage('❌ 回滚失败: ' + (data.error || '未知错误'), 'error');
546
+ }
547
+ } catch (err) {
548
+ showMessage('❌ 回滚失败: ' + err.message, 'error');
549
+ }
550
+ }
551
+
552
+ // 删除备份
553
+ async function deleteBackup(backupId) {
554
+ if (!confirm(`确定要删除备份 "${backupId}" 吗?此操作不可恢复。`)) {
555
+ return;
556
+ }
557
+
558
+ try {
559
+ const res = await fetch(`/api/backups/${backupId}`, { method: 'DELETE' });
560
+ const data = await res.json();
561
+
562
+ if (data.success) {
563
+ showMessage('✅ 删除成功!', 'success');
564
+ await loadBackups();
565
+ } else {
566
+ showMessage('❌ 删除失败: ' + (data.error || '未知错误'), 'error');
567
+ }
568
+ } catch (err) {
569
+ showMessage('❌ 删除失败: ' + err.message, 'error');
570
+ }
571
+ }
572
+
573
+ // 切换自动备份
574
+ document.getElementById('auto-backup-toggle').addEventListener('change', async (e) => {
575
+ try {
576
+ const res = await fetch('/api/config', {
577
+ method: 'POST',
578
+ headers: { 'Content-Type': 'application/json' },
579
+ body: JSON.stringify({ autoBackup: e.target.checked })
580
+ });
581
+ const data = await res.json();
582
+
583
+ if (data.success) {
584
+ showMessage(e.target.checked ? '✅ 自动备份已开启' : '⏹️ 自动备份已关闭', 'success');
585
+ loadStatus();
586
+ }
587
+ } catch (err) {
588
+ showMessage('❌ 设置失败: ' + err.message, 'error');
589
+ e.target.checked = !e.target.checked;
590
+ }
591
+ });
592
+
593
+ // 初始化
594
+ loadBackups();
595
+
596
+ // 定时刷新
597
+ setInterval(loadStatus, 30000);
598
+ </script>
599
+ </body>
600
+ </html>