openclaw-rollback 1.0.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.
@@ -0,0 +1,1457 @@
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 Rollback</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, #1a1a2e 0%, #16213e 50%, #0f3460 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
+ .status-item .value.healthy {
83
+ color: #28a745;
84
+ }
85
+
86
+ .status-item .value.stopped,
87
+ .status-item .value.unhealthy {
88
+ color: #dc3545;
89
+ }
90
+
91
+ .status-item .value.warning {
92
+ color: #ffc107;
93
+ }
94
+
95
+ .btn {
96
+ display: inline-block;
97
+ padding: 10px 20px;
98
+ border: none;
99
+ border-radius: 6px;
100
+ cursor: pointer;
101
+ font-size: 1rem;
102
+ transition: all 0.3s;
103
+ margin-right: 10px;
104
+ margin-bottom: 10px;
105
+ }
106
+
107
+ .btn-primary {
108
+ background: #667eea;
109
+ color: white;
110
+ }
111
+
112
+ .btn-primary:hover {
113
+ background: #5a6fd6;
114
+ }
115
+
116
+ .btn-success {
117
+ background: #28a745;
118
+ color: white;
119
+ }
120
+
121
+ .btn-success:hover {
122
+ background: #218838;
123
+ }
124
+
125
+ .btn-danger {
126
+ background: #dc3545;
127
+ color: white;
128
+ }
129
+
130
+ .btn-danger:hover {
131
+ background: #c82333;
132
+ }
133
+
134
+ .btn-warning {
135
+ background: #ffc107;
136
+ color: #333;
137
+ }
138
+
139
+ .btn-warning:hover {
140
+ background: #e0a800;
141
+ }
142
+
143
+ .btn:disabled {
144
+ opacity: 0.6;
145
+ cursor: not-allowed;
146
+ }
147
+
148
+ .toggle-switch {
149
+ display: flex;
150
+ align-items: center;
151
+ gap: 10px;
152
+ margin-bottom: 20px;
153
+ }
154
+
155
+ .switch {
156
+ position: relative;
157
+ display: inline-block;
158
+ width: 60px;
159
+ height: 34px;
160
+ }
161
+
162
+ .switch input {
163
+ opacity: 0;
164
+ width: 0;
165
+ height: 0;
166
+ }
167
+
168
+ .slider {
169
+ position: absolute;
170
+ cursor: pointer;
171
+ top: 0;
172
+ left: 0;
173
+ right: 0;
174
+ bottom: 0;
175
+ background-color: #ccc;
176
+ transition: .4s;
177
+ border-radius: 34px;
178
+ }
179
+
180
+ .slider:before {
181
+ position: absolute;
182
+ content: "";
183
+ height: 26px;
184
+ width: 26px;
185
+ left: 4px;
186
+ bottom: 4px;
187
+ background-color: white;
188
+ transition: .4s;
189
+ border-radius: 50%;
190
+ }
191
+
192
+ input:checked + .slider {
193
+ background-color: #28a745;
194
+ }
195
+
196
+ input:checked + .slider:before {
197
+ transform: translateX(26px);
198
+ }
199
+
200
+ .backup-list {
201
+ max-height: 400px;
202
+ overflow-y: auto;
203
+ }
204
+
205
+ .backup-item {
206
+ display: flex;
207
+ justify-content: space-between;
208
+ align-items: center;
209
+ padding: 15px;
210
+ background: #f8f9fa;
211
+ border-radius: 8px;
212
+ margin-bottom: 10px;
213
+ }
214
+
215
+ .backup-item:hover {
216
+ background: #e9ecef;
217
+ }
218
+
219
+ .backup-info h4 {
220
+ color: #333;
221
+ margin-bottom: 5px;
222
+ }
223
+
224
+ .backup-info p {
225
+ color: #666;
226
+ font-size: 0.9rem;
227
+ }
228
+
229
+ .backup-info p.backup-items {
230
+ max-height: 2.8em;
231
+ line-height: 1.4em;
232
+ overflow: hidden;
233
+ text-overflow: ellipsis;
234
+ display: -webkit-box;
235
+ -webkit-line-clamp: 2;
236
+ -webkit-box-orient: vertical;
237
+ word-break: break-all;
238
+ }
239
+
240
+ .backup-actions {
241
+ display: flex;
242
+ gap: 10px;
243
+ }
244
+
245
+ .btn-small {
246
+ padding: 6px 12px;
247
+ font-size: 0.85rem;
248
+ }
249
+
250
+ .message {
251
+ position: fixed;
252
+ top: 20px;
253
+ right: 20px;
254
+ padding: 15px 20px;
255
+ border-radius: 8px;
256
+ color: white;
257
+ font-weight: bold;
258
+ z-index: 1000;
259
+ animation: slideIn 0.3s ease;
260
+ }
261
+
262
+ @keyframes slideIn {
263
+ from {
264
+ transform: translateX(100%);
265
+ opacity: 0;
266
+ }
267
+ to {
268
+ transform: translateX(0);
269
+ opacity: 1;
270
+ }
271
+ }
272
+
273
+ .message.success {
274
+ background: #28a745;
275
+ }
276
+
277
+ .message.error {
278
+ background: #dc3545;
279
+ }
280
+
281
+ .message.info {
282
+ background: #17a2b8;
283
+ }
284
+
285
+ .message.warning {
286
+ background: #ffc107;
287
+ color: #333;
288
+ }
289
+
290
+ .empty-state {
291
+ text-align: center;
292
+ padding: 40px;
293
+ color: #666;
294
+ }
295
+
296
+ .empty-state svg {
297
+ width: 64px;
298
+ height: 64px;
299
+ margin-bottom: 15px;
300
+ opacity: 0.5;
301
+ }
302
+
303
+ .loading {
304
+ display: inline-block;
305
+ width: 20px;
306
+ height: 20px;
307
+ border: 3px solid #f3f3f3;
308
+ border-top: 3px solid #667eea;
309
+ border-radius: 50%;
310
+ animation: spin 1s linear infinite;
311
+ }
312
+
313
+ @keyframes spin {
314
+ 0% { transform: rotate(0deg); }
315
+ 100% { transform: rotate(360deg); }
316
+ }
317
+
318
+ .modal {
319
+ display: none;
320
+ position: fixed;
321
+ top: 0;
322
+ left: 0;
323
+ width: 100%;
324
+ height: 100%;
325
+ background: rgba(0, 0, 0, 0.5);
326
+ z-index: 2000;
327
+ justify-content: center;
328
+ align-items: center;
329
+ }
330
+
331
+ .modal.active {
332
+ display: flex;
333
+ }
334
+
335
+ .modal-content {
336
+ background: white;
337
+ padding: 30px;
338
+ border-radius: 12px;
339
+ max-width: 400px;
340
+ text-align: center;
341
+ }
342
+
343
+ .modal-content h3 {
344
+ margin-bottom: 15px;
345
+ color: #333;
346
+ }
347
+
348
+ .modal-content p {
349
+ color: #666;
350
+ margin-bottom: 20px;
351
+ }
352
+
353
+ .modal-actions {
354
+ display: flex;
355
+ gap: 10px;
356
+ justify-content: center;
357
+ }
358
+
359
+ /* 出行模式特殊样式 */
360
+ .travel-mode-card {
361
+ border-left: 4px solid #ffc107;
362
+ }
363
+
364
+ .travel-mode-card.active {
365
+ border-left: 4px solid #28a745;
366
+ }
367
+
368
+ .travel-mode-status {
369
+ display: flex;
370
+ align-items: center;
371
+ gap: 8px;
372
+ padding: 8px 12px;
373
+ border-radius: 6px;
374
+ font-weight: bold;
375
+ margin-bottom: 15px;
376
+ }
377
+
378
+ .travel-mode-status.on {
379
+ background: #d4edda;
380
+ color: #155724;
381
+ }
382
+
383
+ .travel-mode-status.off {
384
+ background: #f8d7da;
385
+ color: #721c24;
386
+ }
387
+
388
+ .travel-badge {
389
+ display: inline-block;
390
+ padding: 2px 8px;
391
+ border-radius: 4px;
392
+ font-size: 0.75rem;
393
+ font-weight: bold;
394
+ margin-left: 8px;
395
+ }
396
+
397
+ .travel-badge.on {
398
+ background: #ffc107;
399
+ color: #333;
400
+ }
401
+
402
+ .travel-badge.off {
403
+ background: #6c757d;
404
+ color: white;
405
+ }
406
+
407
+ .form-group {
408
+ margin-bottom: 15px;
409
+ }
410
+
411
+ .form-group label {
412
+ display: block;
413
+ margin-bottom: 5px;
414
+ color: #333;
415
+ font-weight: 500;
416
+ }
417
+
418
+ .form-group select,
419
+ .form-group input[type="number"] {
420
+ width: 100%;
421
+ padding: 8px 12px;
422
+ border: 1px solid #ddd;
423
+ border-radius: 6px;
424
+ font-size: 1rem;
425
+ }
426
+
427
+ .info-box {
428
+ background: #e7f3ff;
429
+ border: 1px solid #b3d9ff;
430
+ border-radius: 8px;
431
+ padding: 12px;
432
+ margin-bottom: 15px;
433
+ color: #004085;
434
+ font-size: 0.9rem;
435
+ }
436
+
437
+ .info-box p {
438
+ margin: 0;
439
+ }
440
+
441
+ .last-check-info {
442
+ display: grid;
443
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
444
+ gap: 10px;
445
+ margin-top: 15px;
446
+ }
447
+
448
+ .last-check-item {
449
+ background: #f8f9fa;
450
+ padding: 10px;
451
+ border-radius: 6px;
452
+ text-align: center;
453
+ }
454
+
455
+ .last-check-item .label {
456
+ font-size: 0.8rem;
457
+ color: #666;
458
+ }
459
+
460
+ .last-check-item .value {
461
+ font-size: 1rem;
462
+ font-weight: bold;
463
+ color: #333;
464
+ }
465
+
466
+ /* Tab 导航 */
467
+ .tab-nav {
468
+ display: flex;
469
+ gap: 4px;
470
+ margin-bottom: 20px;
471
+ background: rgba(255,255,255,0.1);
472
+ padding: 6px;
473
+ border-radius: 10px;
474
+ flex-wrap: wrap;
475
+ }
476
+ .tab-btn {
477
+ padding: 10px 18px;
478
+ border: none;
479
+ border-radius: 8px;
480
+ background: transparent;
481
+ color: rgba(255,255,255,0.7);
482
+ cursor: pointer;
483
+ font-size: 0.95rem;
484
+ transition: all 0.3s;
485
+ }
486
+ .tab-btn:hover { color: white; }
487
+ .tab-btn.active {
488
+ background: white;
489
+ color: #333;
490
+ font-weight: bold;
491
+ }
492
+
493
+ /* 页面内容 */
494
+ .tab-page { display: none; }
495
+ .tab-page.active { display: block; }
496
+
497
+ /* 图谱可视化 */
498
+ .graph-container {
499
+ width: 100%;
500
+ height: 500px;
501
+ background: #f8f9fa;
502
+ border-radius: 8px;
503
+ position: relative;
504
+ overflow: hidden;
505
+ }
506
+ .graph-node {
507
+ position: absolute;
508
+ padding: 8px 14px;
509
+ border-radius: 20px;
510
+ font-size: 0.85rem;
511
+ font-weight: 500;
512
+ cursor: pointer;
513
+ transition: transform 0.2s, box-shadow 0.2s;
514
+ border: 2px solid;
515
+ white-space: nowrap;
516
+ user-select: none;
517
+ }
518
+ .graph-node:hover {
519
+ transform: scale(1.08);
520
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
521
+ z-index: 100;
522
+ }
523
+ .graph-node.gateway { background: #e3f2fd; border-color: #2196f3; color: #1565c0; }
524
+ .graph-node.auth { background: #fce4ec; border-color: #e91e63; color: #c2185b; }
525
+ .graph-node.network { background: #e8f5e9; border-color: #4caf50; color: #2e7d32; }
526
+ .graph-node.agent { background: #fff3e0; border-color: #ff9800; color: #ef6c00; }
527
+ .graph-node.skill { background: #f3e5f5; border-color: #9c27b0; color: #7b1fa2; }
528
+ .graph-node.plugin { background: #e0f7fa; border-color: #00bcd4; color: #00838f; }
529
+ .graph-node.other { background: #eceff1; border-color: #607d8b; color: #455a64; }
530
+ .graph-edge {
531
+ position: absolute;
532
+ height: 2px;
533
+ background: #bdbdbd;
534
+ transform-origin: left center;
535
+ z-index: 1;
536
+ }
537
+ .graph-edge.depends_on { background: #f44336; height: 3px; }
538
+ .graph-edge.influences { background: #2196f3; }
539
+ .graph-edge.references { background: #4caf50; border-top: 1px dashed; }
540
+ .graph-edge.conflicts_with { background: #ff9800; height: 3px; border-top: 2px dashed; }
541
+
542
+ /* 根因报告 */
543
+ .root-cause-result {
544
+ background: #f8f9fa;
545
+ border-radius: 8px;
546
+ padding: 20px;
547
+ margin-top: 15px;
548
+ }
549
+ .confidence-bar {
550
+ height: 24px;
551
+ background: #e0e0e0;
552
+ border-radius: 12px;
553
+ overflow: hidden;
554
+ margin: 10px 0;
555
+ }
556
+ .confidence-fill {
557
+ height: 100%;
558
+ border-radius: 12px;
559
+ transition: width 0.5s ease;
560
+ display: flex;
561
+ align-items: center;
562
+ justify-content: center;
563
+ color: white;
564
+ font-size: 0.85rem;
565
+ font-weight: bold;
566
+ }
567
+ .evidence-list {
568
+ list-style: none;
569
+ padding: 0;
570
+ }
571
+ .evidence-list li {
572
+ padding: 8px 12px;
573
+ background: white;
574
+ border-radius: 6px;
575
+ margin-bottom: 6px;
576
+ border-left: 3px solid #667eea;
577
+ font-size: 0.9rem;
578
+ }
579
+ .recommendation-box {
580
+ background: #e3f2fd;
581
+ border: 1px solid #90caf9;
582
+ border-radius: 8px;
583
+ padding: 15px;
584
+ margin-top: 15px;
585
+ color: #1565c0;
586
+ }
587
+
588
+ /* 影响分析 */
589
+ .impact-node {
590
+ display: inline-block;
591
+ padding: 4px 10px;
592
+ border-radius: 12px;
593
+ font-size: 0.85rem;
594
+ margin: 3px;
595
+ background: #e8eaf6;
596
+ color: #3f51b5;
597
+ }
598
+ .impact-node.direct { background: #ffebee; color: #c62828; }
599
+ .impact-node.indirect { background: #fff8e1; color: #f57f17; }
600
+ .risk-high { color: #c62828; font-weight: bold; }
601
+ .risk-medium { color: #f57f17; font-weight: bold; }
602
+ .risk-low { color: #2e7d32; }
603
+
604
+ /* 变更历史 */
605
+ .change-item {
606
+ padding: 12px 15px;
607
+ background: #f8f9fa;
608
+ border-radius: 8px;
609
+ margin-bottom: 8px;
610
+ border-left: 3px solid #667eea;
611
+ }
612
+ .change-item .change-meta {
613
+ font-size: 0.8rem;
614
+ color: #666;
615
+ margin-bottom: 4px;
616
+ }
617
+ .change-item .change-diff {
618
+ font-family: monospace;
619
+ font-size: 0.9rem;
620
+ background: white;
621
+ padding: 8px;
622
+ border-radius: 4px;
623
+ margin-top: 6px;
624
+ }
625
+ .change-item .old-val { color: #c62828; text-decoration: line-through; }
626
+ .change-item .new-val { color: #2e7d32; font-weight: bold; }
627
+ </style>
628
+ </head>
629
+ <body>
630
+ <div class="container">
631
+ <header>
632
+ <h1>🔄 OpenClaw Rollback</h1>
633
+ <p>OpenClaw 自动回滚 - 智能备份与出行模式守护</p>
634
+ </header>
635
+
636
+ <nav class="tab-nav">
637
+ <button class="tab-btn active" onclick="switchTab('main')">📊 主控台</button>
638
+ <button class="tab-btn" onclick="switchTab('graph')">🕸️ 影响图谱</button>
639
+ <button class="tab-btn" onclick="switchTab('rootcause')">🎯 根因定位</button>
640
+ <button class="tab-btn" onclick="switchTab('changes')">📝 变更历史</button>
641
+ </nav>
642
+
643
+ <div class="card">
644
+ <h2>📊 系统状态</h2>
645
+ <div class="status-grid">
646
+ <div class="status-item">
647
+ <div class="label">OpenClaw Gateway</div>
648
+ <div class="value" id="gateway-status">检测中...</div>
649
+ </div>
650
+ <div class="status-item">
651
+ <div class="label">健康状态</div>
652
+ <div class="value" id="health-status">检测中...</div>
653
+ </div>
654
+ <div class="status-item">
655
+ <div class="label">自动备份</div>
656
+ <div class="value" id="auto-backup">检测中...</div>
657
+ </div>
658
+ <div class="status-item">
659
+ <div class="label">备份数量</div>
660
+ <div class="value" id="backup-count">-</div>
661
+ </div>
662
+ <div class="status-item">
663
+ <div class="label">上次备份</div>
664
+ <div class="value" id="last-backup">-</div>
665
+ </div>
666
+ <div class="status-item">
667
+ <div class="label">出行模式</div>
668
+ <div class="value" id="travel-mode-status">检测中...</div>
669
+ </div>
670
+ </div>
671
+ </div>
672
+
673
+ <!-- 出行模式卡片 -->
674
+ <div class="card travel-mode-card" id="travel-card">
675
+ <h2>✈️ 出行模式 (Travel Mode)</h2>
676
+ <div class="info-box">
677
+ <p>💡 <strong>适用场景:</strong>黑盒子服务器(无显示器)或出差期间无法操作服务器。</p>
678
+ <p style="margin-top:5px">开启后,系统会定时检测 Gateway 健康状态:<strong>正常则自动备份</strong>,<strong>异常则自动回滚</strong>到最后已知良好的配置。</p>
679
+ </div>
680
+
681
+ <div class="travel-mode-status off" id="travel-status-bar">
682
+ <span id="travel-status-icon">✈️</span>
683
+ <span id="travel-status-text">出行模式已关闭</span>
684
+ </div>
685
+
686
+ <div class="toggle-switch">
687
+ <label class="switch">
688
+ <input type="checkbox" id="travel-mode-toggle">
689
+ <span class="slider"></span>
690
+ </label>
691
+ <span id="travel-mode-label">开启出行模式</span>
692
+ </div>
693
+
694
+ <div id="travel-config" style="display: none; margin-top: 15px;">
695
+ <div class="form-group">
696
+ <label for="travel-interval">检查间隔</label>
697
+ <select id="travel-interval">
698
+ <option value="10">每 10 分钟(高频监控)</option>
699
+ <option value="120" selected>每 2 小时(推荐)</option>
700
+ <option value="480">每 8 小时(低频监控)</option>
701
+ </select>
702
+ </div>
703
+
704
+ <div class="form-group">
705
+ <label for="travel-max-backups">最大保留备份数</label>
706
+ <input type="number" id="travel-max-backups" value="20" min="5" max="50">
707
+ </div>
708
+
709
+ <button class="btn btn-success" onclick="saveTravelConfig()">
710
+ 💾 保存出行模式配置
711
+ </button>
712
+ <button class="btn btn-warning" onclick="manualTravelCheck()">
713
+ 🔍 立即检测一次
714
+ </button>
715
+ </div>
716
+
717
+ <div class="last-check-info" id="last-check-info" style="display: none;">
718
+ <div class="last-check-item">
719
+ <div class="label">最后检查</div>
720
+ <div class="value" id="last-check-time">-</div>
721
+ </div>
722
+ <div class="last-check-item">
723
+ <div class="label">检查结果</div>
724
+ <div class="value" id="last-check-result">-</div>
725
+ </div>
726
+ <div class="last-check-item">
727
+ <div class="label">连续异常</div>
728
+ <div class="value" id="consecutive-failures">-</div>
729
+ </div>
730
+ <div class="last-check-item">
731
+ <div class="label">最后良好备份</div>
732
+ <div class="value" id="last-good-backup">-</div>
733
+ </div>
734
+ </div>
735
+ </div>
736
+
737
+ <div class="card">
738
+ <h2>⚙️ 自动备份设置</h2>
739
+ <div class="toggle-switch">
740
+ <label class="switch">
741
+ <input type="checkbox" id="auto-backup-toggle">
742
+ <span class="slider"></span>
743
+ </label>
744
+ <span id="auto-backup-text">自动备份(定时任务)</span>
745
+ </div>
746
+ <div id="auto-backup-schedule" style="margin: 10px 0; color: #666; font-size: 0.9rem;">
747
+ ⏰ 下次自动备份:每天凌晨 2:00
748
+ </div>
749
+ <button class="btn btn-success" id="backup-now-btn" onclick="createBackup()">
750
+ 💾 立即备份
751
+ </button>
752
+ </div>
753
+
754
+ <div class="card">
755
+ <h2>📦 备份列表</h2>
756
+ <div class="backup-list" id="backup-list">
757
+ <div class="loading"></div>
758
+ </div>
759
+ </div>
760
+ </div>
761
+
762
+ <!-- ========== 影响图谱页面 ========== -->
763
+ <div class="tab-page" id="page-graph">
764
+ <div class="card">
765
+ <h2>🕸️ 配置影响图谱</h2>
766
+ <p style="color:#666;margin-bottom:15px;">可视化展示配置项之间的依赖与影响关系。点击节点可查看详细影响分析。</p>
767
+ <div class="graph-container" id="graph-viz">
768
+ <div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;">
769
+ <div class="loading"></div>
770
+ <span style="margin-left:10px;">正在构建图谱...</span>
771
+ </div>
772
+ </div>
773
+ <div style="margin-top:10px;display:flex;gap:15px;flex-wrap:wrap;font-size:0.8rem;color:#666;">
774
+ <span>🟦 Gateway</span><span>🟥 认证</span><span>🟩 网络</span>
775
+ <span>🟧 Agent</span><span>🟪 Skill</span><span>🟦 插件</span>
776
+ </div>
777
+ </div>
778
+
779
+ <div class="card" id="impact-detail-card" style="display:none;">
780
+ <h2>🔍 变更影响分析</h2>
781
+ <div id="impact-detail"></div>
782
+ </div>
783
+ </div>
784
+
785
+ <!-- ========== 根因定位页面 ========== -->
786
+ <div class="tab-page" id="page-rootcause">
787
+ <div class="card">
788
+ <h2>🎯 故障根因定位</h2>
789
+ <p style="color:#666;margin-bottom:15px;">输入故障现象,系统自动分析可能的根因配置项和修复建议。</p>
790
+
791
+ <div class="form-group">
792
+ <label>故障类型</label>
793
+ <select id="rc-fault-type">
794
+ <option value="port_unreachable">端口不可达</option>
795
+ <option value="auth_401">认证失败 (401)</option>
796
+ <option value="auth_403">权限不足 (403)</option>
797
+ <option value="process_crash">进程崩溃</option>
798
+ <option value="config_parse_error">配置解析错误</option>
799
+ <option value="plugin_load_fail">插件加载失败</option>
800
+ <option value="agent_connect_fail">Agent连接失败</option>
801
+ <option value="unknown">其他/未知</option>
802
+ </select>
803
+ </div>
804
+ <div class="form-group">
805
+ <label>故障详情描述</label>
806
+ <input type="text" id="rc-fault-detail" placeholder="例如:Gateway端口无响应,curl返回Connection refused"
807
+ style="width:100%;padding:8px 12px;border:1px solid #ddd;border-radius:6px;">
808
+ </div>
809
+ <button class="btn btn-primary" onclick="runRootCause()">
810
+ 🔍 开始根因分析
811
+ </button>
812
+
813
+ <div id="root-cause-result"></div>
814
+ </div>
815
+ </div>
816
+
817
+ <!-- ========== 变更历史页面 ========== -->
818
+ <div class="tab-page" id="page-changes">
819
+ <div class="card">
820
+ <h2>📝 配置变更历史</h2>
821
+ <p style="color:#666;margin-bottom:15px;">记录所有配置变更,用于故障追溯和根因分析。</p>
822
+ <div id="changes-list">
823
+ <div style="display:flex;align-items:center;justify-content:center;padding:40px;color:#999;">
824
+ <div class="loading"></div>
825
+ <span style="margin-left:10px;">加载中...</span>
826
+ </div>
827
+ </div>
828
+ </div>
829
+ </div>
830
+
831
+ <!-- 确认对话框 -->
832
+ <div class="modal" id="confirm-modal">
833
+ <div class="modal-content">
834
+ <h3>⚠️ 确认回滚</h3>
835
+ <p id="confirm-message">确定要回滚到这个备份吗?当前配置将被覆盖。</p>
836
+ <div class="modal-actions">
837
+ <button class="btn btn-danger" id="confirm-btn">确认回滚</button>
838
+ <button class="btn" onclick="closeModal()">取消</button>
839
+ </div>
840
+ </div>
841
+ </div>
842
+
843
+ <script>
844
+ let currentBackups = [];
845
+ let selectedBackupId = null;
846
+ let currentConfig = {};
847
+
848
+ // 显示消息
849
+ function showMessage(text, type = 'info') {
850
+ const msg = document.createElement('div');
851
+ msg.className = `message ${type}`;
852
+ msg.textContent = text;
853
+ document.body.appendChild(msg);
854
+ setTimeout(() => msg.remove(), 3000);
855
+ }
856
+
857
+ // 获取状态
858
+ async function loadStatus() {
859
+ try {
860
+ const res = await fetch('/api/status');
861
+ const data = await res.json();
862
+ currentConfig = data.config;
863
+
864
+ const gatewayStatus = document.getElementById('gateway-status');
865
+ const healthStatus = document.getElementById('health-status');
866
+
867
+ if (data.gatewayHealthy) {
868
+ gatewayStatus.textContent = '运行中';
869
+ gatewayStatus.className = 'value running';
870
+ healthStatus.textContent = '健康';
871
+ healthStatus.className = 'value healthy';
872
+ } else {
873
+ gatewayStatus.textContent = data.gateway === 'running' ? '运行中' : '已停止';
874
+ gatewayStatus.className = 'value ' + (data.gateway === 'running' ? 'warning' : 'stopped');
875
+ healthStatus.textContent = '异常';
876
+ healthStatus.className = 'value unhealthy';
877
+ }
878
+
879
+ const autoBackup = document.getElementById('auto-backup');
880
+ autoBackup.textContent = data.config.autoBackup ? '已开启' : '已关闭';
881
+
882
+ const toggle = document.getElementById('auto-backup-toggle');
883
+ toggle.checked = data.config.autoBackup;
884
+
885
+ document.getElementById('backup-count').textContent = currentBackups.length;
886
+ document.getElementById('last-backup').textContent =
887
+ data.config.lastBackup ? new Date(data.config.lastBackup).toLocaleString() : '无';
888
+
889
+ // 更新出行模式状态
890
+ updateTravelModeUI(data.config);
891
+ } catch (err) {
892
+ console.error('加载状态失败:', err);
893
+ }
894
+ }
895
+
896
+ // 更新出行模式 UI
897
+ function updateTravelModeUI(config) {
898
+ const travelToggle = document.getElementById('travel-mode-toggle');
899
+ const travelCard = document.getElementById('travel-card');
900
+ const travelStatusBar = document.getElementById('travel-status-bar');
901
+ const travelStatusText = document.getElementById('travel-status-text');
902
+ const travelConfig = document.getElementById('travel-config');
903
+ const lastCheckInfo = document.getElementById('last-check-info');
904
+ const travelModeStatus = document.getElementById('travel-mode-status');
905
+
906
+ travelToggle.checked = config.travelMode;
907
+
908
+ if (config.travelMode) {
909
+ travelCard.classList.add('active');
910
+ travelStatusBar.className = 'travel-mode-status on';
911
+ travelStatusText.textContent = '✈️ 出行模式已开启';
912
+ travelConfig.style.display = 'block';
913
+ lastCheckInfo.style.display = 'grid';
914
+ travelModeStatus.textContent = '已开启';
915
+ travelModeStatus.className = 'value healthy';
916
+
917
+ // 设置表单值
918
+ document.getElementById('travel-interval').value = config.travelModeInterval || 120;
919
+ document.getElementById('travel-max-backups').value = config.travelModeMaxBackups || 20;
920
+
921
+ // 更新检查信息
922
+ document.getElementById('last-check-time').textContent =
923
+ config.lastCheckTime ? new Date(config.lastCheckTime).toLocaleString() : '无';
924
+ document.getElementById('last-check-result').textContent =
925
+ config.lastCheckResult === 'healthy' ? '✅ 正常' :
926
+ config.lastCheckResult === 'unhealthy' ? '❌ 异常' : '未知';
927
+ document.getElementById('consecutive-failures').textContent =
928
+ config.consecutiveFailures || 0;
929
+ document.getElementById('last-good-backup').textContent =
930
+ config.lastKnownGoodBackup ? '有' : '无';
931
+ } else {
932
+ travelCard.classList.remove('active');
933
+ travelStatusBar.className = 'travel-mode-status off';
934
+ travelStatusText.textContent = '✈️ 出行模式已关闭';
935
+ travelConfig.style.display = 'none';
936
+ lastCheckInfo.style.display = 'none';
937
+ travelModeStatus.textContent = '已关闭';
938
+ travelModeStatus.className = 'value stopped';
939
+ }
940
+ }
941
+
942
+ // 保存出行模式配置
943
+ async function saveTravelConfig() {
944
+ const interval = parseInt(document.getElementById('travel-interval').value, 10);
945
+ const maxBackups = parseInt(document.getElementById('travel-max-backups').value, 10);
946
+
947
+ try {
948
+ const res = await fetch('/api/config', {
949
+ method: 'POST',
950
+ headers: { 'Content-Type': 'application/json' },
951
+ body: JSON.stringify({
952
+ travelMode: true,
953
+ travelModeInterval: interval,
954
+ travelModeMaxBackups: maxBackups
955
+ })
956
+ });
957
+ const data = await res.json();
958
+
959
+ if (data.success) {
960
+ showMessage('✅ 出行模式配置已保存', 'success');
961
+ loadStatus();
962
+ }
963
+ } catch (err) {
964
+ showMessage('❌ 保存失败: ' + err.message, 'error');
965
+ }
966
+ }
967
+
968
+ // 手动触发出行模式检测
969
+ async function manualTravelCheck() {
970
+ showMessage('🔍 正在检测 Gateway 健康状态...', 'info');
971
+ try {
972
+ const res = await fetch('/api/travel-mode/check', { method: 'POST' });
973
+ const data = await res.json();
974
+
975
+ if (data.success) {
976
+ if (data.healthy) {
977
+ showMessage('✅ Gateway 健康,已创建出行模式备份', 'success');
978
+ } else {
979
+ showMessage('🚨 Gateway 异常,已执行自动回滚', 'warning');
980
+ }
981
+ await loadBackups();
982
+ await loadStatus();
983
+ } else {
984
+ showMessage('❌ 检测失败: ' + (data.error || '未知错误'), 'error');
985
+ }
986
+ } catch (err) {
987
+ showMessage('❌ 检测失败: ' + err.message, 'error');
988
+ }
989
+ }
990
+
991
+ // 获取备份列表
992
+ async function loadBackups() {
993
+ try {
994
+ const res = await fetch('/api/backups');
995
+ currentBackups = await res.json();
996
+ renderBackups();
997
+ loadStatus();
998
+ } catch (err) {
999
+ console.error('加载备份失败:', err);
1000
+ document.getElementById('backup-list').innerHTML =
1001
+ '<div class="empty-state">加载失败,请刷新页面重试</div>';
1002
+ }
1003
+ }
1004
+
1005
+ // 渲染备份列表
1006
+ function renderBackups() {
1007
+ const container = document.getElementById('backup-list');
1008
+
1009
+ if (currentBackups.length === 0) {
1010
+ container.innerHTML = `
1011
+ <div class="empty-state">
1012
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1013
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
1014
+ </svg>
1015
+ <p>暂无备份</p>
1016
+ <p style="font-size: 0.9rem; margin-top: 10px;">点击"立即备份"创建第一个备份</p>
1017
+ </div>
1018
+ `;
1019
+ return;
1020
+ }
1021
+
1022
+ container.innerHTML = currentBackups.map(backup => {
1023
+ let itemsDisplay = '';
1024
+ if (backup.items && backup.items.length > 0) {
1025
+ const maxChars = 80;
1026
+ let currentLength = 0;
1027
+ const shownItems = [];
1028
+
1029
+ for (const item of backup.items) {
1030
+ if (currentLength + item.length + 2 <= maxChars) {
1031
+ shownItems.push(item);
1032
+ currentLength += item.length + 2;
1033
+ } else {
1034
+ break;
1035
+ }
1036
+ }
1037
+
1038
+ itemsDisplay = shownItems.join(', ');
1039
+ const remaining = backup.items.length - shownItems.length;
1040
+ if (remaining > 0) {
1041
+ itemsDisplay += ` 等 ${backup.items.length} 项`;
1042
+ }
1043
+ } else {
1044
+ itemsDisplay = '无';
1045
+ }
1046
+
1047
+ const travelBadge = backup.isTravelMode
1048
+ ? `<span class="travel-badge on">✈️ 出行</span>`
1049
+ : '';
1050
+ const healthBadge = backup.gatewayHealthy === false
1051
+ ? `<span class="travel-badge off" style="background:#dc3545;color:white">❌ 异常</span>`
1052
+ : backup.gatewayHealthy === true
1053
+ ? `<span class="travel-badge off" style="background:#28a745;color:white">✅ 正常</span>`
1054
+ : '';
1055
+
1056
+ return `
1057
+ <div class="backup-item">
1058
+ <div class="backup-info">
1059
+ <h4>${backup.id}${travelBadge}${healthBadge}</h4>
1060
+ <p>时间: ${new Date(backup.timestamp).toLocaleString()}</p>
1061
+ <p class="backup-items" title="${backup.items ? backup.items.join(', ') : ''}">项目: ${itemsDisplay}</p>
1062
+ </div>
1063
+ <div class="backup-actions">
1064
+ <button class="btn btn-primary btn-small" onclick="confirmRestore('${backup.id}')">
1065
+ 🔄 回滚
1066
+ </button>
1067
+ <button class="btn btn-danger btn-small" onclick="deleteBackup('${backup.id}')">
1068
+ 🗑️ 删除
1069
+ </button>
1070
+ </div>
1071
+ </div>
1072
+ `;
1073
+ }).join('');
1074
+ }
1075
+
1076
+ // 创建备份
1077
+ async function createBackup() {
1078
+ const btn = document.getElementById('backup-now-btn');
1079
+ btn.disabled = true;
1080
+ btn.innerHTML = '<span class="loading"></span> 备份中...';
1081
+
1082
+ try {
1083
+ const res = await fetch('/api/backup', { method: 'POST' });
1084
+ const data = await res.json();
1085
+
1086
+ if (data.success) {
1087
+ showMessage('✅ 备份成功!', 'success');
1088
+ await loadBackups();
1089
+ } else {
1090
+ showMessage('❌ 备份失败: ' + (data.error || '未知错误'), 'error');
1091
+ }
1092
+ } catch (err) {
1093
+ showMessage('❌ 备份失败: ' + err.message, 'error');
1094
+ } finally {
1095
+ btn.disabled = false;
1096
+ btn.innerHTML = '💾 立即备份';
1097
+ }
1098
+ }
1099
+
1100
+ // 确认回滚
1101
+ function confirmRestore(backupId) {
1102
+ selectedBackupId = backupId;
1103
+ document.getElementById('confirm-message').textContent =
1104
+ `确定要回滚到 "${backupId}" 吗?当前配置将被覆盖。`;
1105
+ document.getElementById('confirm-modal').classList.add('active');
1106
+
1107
+ document.getElementById('confirm-btn').onclick = () => {
1108
+ closeModal();
1109
+ restoreBackup(backupId);
1110
+ };
1111
+ }
1112
+
1113
+ // 关闭对话框
1114
+ function closeModal() {
1115
+ document.getElementById('confirm-modal').classList.remove('active');
1116
+ selectedBackupId = null;
1117
+ }
1118
+
1119
+ // 回滚备份
1120
+ async function restoreBackup(backupId) {
1121
+ showMessage('🔄 正在回滚...', 'info');
1122
+
1123
+ try {
1124
+ const res = await fetch('/api/restore', {
1125
+ method: 'POST',
1126
+ headers: { 'Content-Type': 'application/json' },
1127
+ body: JSON.stringify({ id: backupId })
1128
+ });
1129
+ const data = await res.json();
1130
+
1131
+ if (data.success) {
1132
+ const message = data.message || '回滚成功!';
1133
+ if (data.gatewayStarted) {
1134
+ showMessage('✅ ' + message, 'success');
1135
+ } else {
1136
+ showMessage('⚠️ ' + message, 'error');
1137
+ }
1138
+ await loadBackups();
1139
+ await loadStatus();
1140
+ } else {
1141
+ showMessage('❌ 回滚失败: ' + (data.error || '未知错误'), 'error');
1142
+ }
1143
+ } catch (err) {
1144
+ showMessage('❌ 回滚失败: ' + err.message, 'error');
1145
+ }
1146
+ }
1147
+
1148
+ // 删除备份
1149
+ async function deleteBackup(backupId) {
1150
+ if (!confirm(`确定要删除备份 "${backupId}" 吗?此操作不可恢复。`)) {
1151
+ return;
1152
+ }
1153
+
1154
+ try {
1155
+ const res = await fetch(`/api/backups/${backupId}`, { method: 'DELETE' });
1156
+ const data = await res.json();
1157
+
1158
+ if (data.success) {
1159
+ showMessage('✅ 删除成功!', 'success');
1160
+ await loadBackups();
1161
+ } else {
1162
+ showMessage('❌ 删除失败: ' + (data.error || '未知错误'), 'error');
1163
+ }
1164
+ } catch (err) {
1165
+ showMessage('❌ 删除失败: ' + err.message, 'error');
1166
+ }
1167
+ }
1168
+
1169
+ // 切换自动备份
1170
+ document.getElementById('auto-backup-toggle').addEventListener('change', async (e) => {
1171
+ try {
1172
+ const res = await fetch('/api/config', {
1173
+ method: 'POST',
1174
+ headers: { 'Content-Type': 'application/json' },
1175
+ body: JSON.stringify({ autoBackup: e.target.checked })
1176
+ });
1177
+ const data = await res.json();
1178
+
1179
+ if (data.success) {
1180
+ showMessage(e.target.checked ? '✅ 自动备份已开启' : '⏹️ 自动备份已关闭', 'success');
1181
+ loadStatus();
1182
+ }
1183
+ } catch (err) {
1184
+ showMessage('❌ 设置失败: ' + err.message, 'error');
1185
+ e.target.checked = !e.target.checked;
1186
+ }
1187
+ });
1188
+
1189
+ // 切换出行模式
1190
+ document.getElementById('travel-mode-toggle').addEventListener('change', async (e) => {
1191
+ try {
1192
+ const res = await fetch('/api/config', {
1193
+ method: 'POST',
1194
+ headers: { 'Content-Type': 'application/json' },
1195
+ body: JSON.stringify({ travelMode: e.target.checked })
1196
+ });
1197
+ const data = await res.json();
1198
+
1199
+ if (data.success) {
1200
+ showMessage(e.target.checked ? '✅ 出行模式已开启' : '⏹️ 出行模式已关闭', 'success');
1201
+ loadStatus();
1202
+ }
1203
+ } catch (err) {
1204
+ showMessage('❌ 设置失败: ' + err.message, 'error');
1205
+ e.target.checked = !e.target.checked;
1206
+ }
1207
+ });
1208
+
1209
+ // ========== Tab 切换 ==========
1210
+ function switchTab(tabName) {
1211
+ document.querySelectorAll('.tab-page').forEach(p => p.classList.remove('active'));
1212
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
1213
+
1214
+ if (tabName === 'main') {
1215
+ document.getElementById('page-main')?.classList.add('active');
1216
+ } else {
1217
+ document.getElementById('page-' + tabName)?.classList.add('active');
1218
+ }
1219
+ event.target.classList.add('active');
1220
+
1221
+ if (tabName === 'graph') loadGraph();
1222
+ if (tabName === 'changes') loadChanges();
1223
+ }
1224
+
1225
+ // ========== 影响图谱 ==========
1226
+ let graphData = null;
1227
+ async function loadGraph() {
1228
+ if (graphData) return;
1229
+ try {
1230
+ const res = await fetch('/api/graph');
1231
+ const data = await res.json();
1232
+ if (data.success) {
1233
+ graphData = data.graph;
1234
+ renderGraph(graphData);
1235
+ }
1236
+ } catch (err) {
1237
+ document.getElementById('graph-viz').innerHTML =
1238
+ '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#c62828;">加载图谱失败: ' + err.message + '</div>';
1239
+ }
1240
+ }
1241
+
1242
+ function renderGraph(graph) {
1243
+ const container = document.getElementById('graph-viz');
1244
+ container.innerHTML = '';
1245
+ const W = container.clientWidth;
1246
+ const H = container.clientHeight;
1247
+
1248
+ // 按类型分组布局
1249
+ const typeOrder = ['gateway', 'auth', 'network', 'agent', 'skill', 'plugin', 'other'];
1250
+ const nodesByType = {};
1251
+ graph.nodes.forEach(n => {
1252
+ if (!nodesByType[n.type]) nodesByType[n.type] = [];
1253
+ nodesByType[n.type].push(n);
1254
+ });
1255
+
1256
+ const positions = {};
1257
+ const cols = 4;
1258
+ let x = 60, y = 60;
1259
+ typeOrder.forEach((type, ti) => {
1260
+ const nodes = nodesByType[type] || [];
1261
+ nodes.forEach((n, i) => {
1262
+ positions[n.id] = {
1263
+ x: 60 + (i % cols) * 180,
1264
+ y: 60 + Math.floor(i / cols) * 70 + ti * 10
1265
+ };
1266
+ });
1267
+ });
1268
+
1269
+ // 如果没有节点,显示提示
1270
+ if (graph.nodes.length === 0) {
1271
+ container.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#999;">暂无配置节点</div>';
1272
+ return;
1273
+ }
1274
+
1275
+ // 绘制边
1276
+ graph.edges.forEach(e => {
1277
+ const s = positions[e.source];
1278
+ const t = positions[e.target];
1279
+ if (!s || !t) return;
1280
+ const dx = t.x - s.x;
1281
+ const dy = t.y - s.y;
1282
+ const dist = Math.sqrt(dx*dx + dy*dy);
1283
+ const angle = Math.atan2(dy, dx) * 180 / Math.PI;
1284
+ const edge = document.createElement('div');
1285
+ edge.className = 'graph-edge ' + e.type;
1286
+ edge.style.left = (s.x + 40) + 'px';
1287
+ edge.style.top = (s.y + 15) + 'px';
1288
+ edge.style.width = (dist - 80) + 'px';
1289
+ edge.style.transform = 'rotate(' + angle + 'deg)';
1290
+ edge.title = e.description + ' (强度:' + e.strength + ')';
1291
+ container.appendChild(edge);
1292
+ });
1293
+
1294
+ // 绘制节点
1295
+ graph.nodes.forEach(n => {
1296
+ const pos = positions[n.id] || { x: Math.random() * (W-150) + 30, y: Math.random() * (H-50) + 30 };
1297
+ const node = document.createElement('div');
1298
+ node.className = 'graph-node ' + n.type;
1299
+ node.style.left = pos.x + 'px';
1300
+ node.style.top = pos.y + 'px';
1301
+ node.textContent = n.id.split('.').pop();
1302
+ node.title = n.id + (n.description ? '\n' + n.description : '');
1303
+ node.onclick = () => showImpact(n.id);
1304
+ container.appendChild(node);
1305
+ positions[n.id] = pos;
1306
+ });
1307
+ }
1308
+
1309
+ // 显示影响分析
1310
+ async function showImpact(nodeId) {
1311
+ document.getElementById('impact-detail-card').style.display = 'block';
1312
+ document.getElementById('impact-detail').innerHTML = '<div class="loading"></div>';
1313
+ try {
1314
+ const res = await fetch('/api/graph/impact?node=' + encodeURIComponent(nodeId));
1315
+ const data = await res.json();
1316
+ if (data.success) {
1317
+ renderImpact(data.report);
1318
+ }
1319
+ } catch (err) {
1320
+ document.getElementById('impact-detail').innerHTML = '加载失败: ' + err.message;
1321
+ }
1322
+ }
1323
+
1324
+ function renderImpact(report) {
1325
+ const r = report;
1326
+ let riskClass = r.riskScore >= 60 ? 'risk-high' : r.riskScore >= 30 ? 'risk-medium' : 'risk-low';
1327
+ let html = `
1328
+ <div style="margin-bottom:15px;">
1329
+ <strong>变更节点:</strong> <code>${r.changedNode}</code>
1330
+ <span class="${riskClass}" style="margin-left:15px;">风险评分: ${r.riskScore}/100</span>
1331
+ </div>
1332
+ `;
1333
+ if (r.warnings.length > 0) {
1334
+ html += '<div style="margin-bottom:15px;">';
1335
+ r.warnings.forEach(w => html += '<div style="color:#c62828;font-size:0.9rem;margin:4px 0;">' + w + '</div>');
1336
+ html += '</div>';
1337
+ }
1338
+ if (r.directImpacts.length > 0) {
1339
+ html += '<div style="margin-bottom:10px;"><strong>📌 直接影响:</strong><br>';
1340
+ r.directImpacts.forEach(i => html += '<span class="impact-node direct">' + i + '</span>');
1341
+ html += '</div>';
1342
+ }
1343
+ if (r.indirectImpacts.length > 0) {
1344
+ html += '<div style="margin-bottom:10px;"><strong>🔗 间接影响:</strong><br>';
1345
+ r.indirectImpacts.forEach(i => html += '<span class="impact-node indirect">' + i + '</span>');
1346
+ html += '</div>';
1347
+ }
1348
+ if (r.affectedFiles.length > 0) {
1349
+ html += '<div><strong>📁 受影响文件:</strong> ' + r.affectedFiles.join(', ') + '</div>';
1350
+ }
1351
+ document.getElementById('impact-detail').innerHTML = html;
1352
+ }
1353
+
1354
+ // ========== 根因定位 ==========
1355
+ async function runRootCause() {
1356
+ const type = document.getElementById('rc-fault-type').value;
1357
+ const detail = document.getElementById('rc-fault-detail').value || 'Gateway unhealthy';
1358
+ const resultDiv = document.getElementById('root-cause-result');
1359
+ resultDiv.innerHTML = '<div style="display:flex;align-items:center;padding:20px;"><div class="loading"></div><span style="margin-left:10px;">分析中...</span></div>';
1360
+
1361
+ try {
1362
+ const res = await fetch('/api/graph/root-cause?type=' + encodeURIComponent(type) + '&detail=' + encodeURIComponent(detail));
1363
+ const data = await res.json();
1364
+ if (data.success) {
1365
+ renderRootCause(data.report);
1366
+ } else {
1367
+ resultDiv.innerHTML = '<div style="color:#c62828;">分析失败: ' + (data.error || '未知错误') + '</div>';
1368
+ }
1369
+ } catch (err) {
1370
+ resultDiv.innerHTML = '<div style="color:#c62828;">请求失败: ' + err.message + '</div>';
1371
+ }
1372
+ }
1373
+
1374
+ function renderRootCause(report) {
1375
+ const rc = report.rootCause;
1376
+ const confidencePct = Math.round(report.confidence * 100);
1377
+ const barColor = confidencePct >= 70 ? '#4caf50' : confidencePct >= 40 ? '#ff9800' : '#f44336';
1378
+
1379
+ let html = '<div class="root-cause-result">';
1380
+ html += '<h3>🎯 根因分析结果</h3>';
1381
+
1382
+ if (rc) {
1383
+ html += '<div style="margin:15px 0;">';
1384
+ html += '<strong>最可能根因:</strong> <code style="font-size:1.1rem;background:#e3f2fd;padding:4px 10px;border-radius:4px;">' + rc.id + '</code>';
1385
+ html += '<div style="margin-top:8px;color:#666;font-size:0.9rem;">' + (rc.description || '') + '</div>';
1386
+ html += '</div>';
1387
+
1388
+ html += '<div><strong>置信度:</strong></div>';
1389
+ html += '<div class="confidence-bar"><div class="confidence-fill" style="width:' + confidencePct + '%;background:' + barColor + '">' + confidencePct + '%</div></div>';
1390
+ } else {
1391
+ html += '<div style="color:#666;margin:15px 0;">无法自动定位根因,建议查看完整变更历史。</div>';
1392
+ }
1393
+
1394
+ if (report.evidence.length > 0) {
1395
+ html += '<h4 style="margin-top:20px;">📋 证据链</h4>';
1396
+ html += '<ul class="evidence-list">';
1397
+ report.evidence.forEach(e => {
1398
+ html += '<li>' + e + '</li>';
1399
+ });
1400
+ html += '</ul>';
1401
+ }
1402
+
1403
+ if (report.relatedChanges.length > 0) {
1404
+ html += '<h4 style="margin-top:20px;">📝 相关变更</h4>';
1405
+ report.relatedChanges.slice(0, 5).forEach(c => {
1406
+ html += '<div class="change-item">';
1407
+ html += '<div class="change-meta">' + new Date(c.timestamp).toLocaleString() + ' | ' + c.file + '</div>';
1408
+ html += '<div><code>' + c.nodeId + '</code></div>';
1409
+ html += '<div class="change-diff"><span class="old-val">' + JSON.stringify(c.oldValue) + '</span> → <span class="new-val">' + JSON.stringify(c.newValue) + '</span></div>';
1410
+ html += '</div>';
1411
+ });
1412
+ }
1413
+
1414
+ html += '<div class="recommendation-box">';
1415
+ html += '<strong>💡 修复建议:</strong><br>' + report.recommendation;
1416
+ html += '</div>';
1417
+
1418
+ html += '</div>';
1419
+ document.getElementById('root-cause-result').innerHTML = html;
1420
+ }
1421
+
1422
+ // ========== 变更历史 ==========
1423
+ async function loadChanges() {
1424
+ try {
1425
+ const res = await fetch('/api/graph/changes');
1426
+ const data = await res.json();
1427
+ const container = document.getElementById('changes-list');
1428
+ if (data.success && data.changes.length > 0) {
1429
+ container.innerHTML = data.changes.map(c => `
1430
+ <div class="change-item">
1431
+ <div class="change-meta">
1432
+ ${new Date(c.timestamp).toLocaleString()} | ${c.file} | ${c.changeType}
1433
+ </div>
1434
+ <div><code>${c.nodeId}</code></div>
1435
+ <div class="change-diff">
1436
+ <span class="old-val">${JSON.stringify(c.oldValue)}</span>
1437
+
1438
+ <span class="new-val">${JSON.stringify(c.newValue)}</span>
1439
+ </div>
1440
+ </div>
1441
+ `).join('');
1442
+ } else {
1443
+ container.innerHTML = '<div class="empty-state">暂无变更记录</div>';
1444
+ }
1445
+ } catch (err) {
1446
+ document.getElementById('changes-list').innerHTML = '<div style="color:#c62828;">加载失败: ' + err.message + '</div>';
1447
+ }
1448
+ }
1449
+
1450
+ // 初始化
1451
+ loadBackups();
1452
+
1453
+ // 定时刷新
1454
+ setInterval(loadStatus, 30000);
1455
+ </script>
1456
+ </body>
1457
+ </html>