opencroc 1.8.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +354 -43
- package/dist/cli/index.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/web/index-studio.html +886 -46
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
|
@@ -124,6 +124,39 @@ body {
|
|
|
124
124
|
.risk-medium { background: var(--warning); color: #000; }
|
|
125
125
|
.risk-low { background: var(--info); color: #fff; }
|
|
126
126
|
|
|
127
|
+
.snapshot-list { list-style: none; }
|
|
128
|
+
.snapshot-search { width: 100%; margin-bottom: 8px; }
|
|
129
|
+
.snapshot-tag-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
|
130
|
+
.snapshot-tag-chip {
|
|
131
|
+
border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary);
|
|
132
|
+
border-radius: 999px; padding: 2px 8px; font-size: 10px; cursor: pointer;
|
|
133
|
+
}
|
|
134
|
+
.snapshot-tag-chip.active { border-color: var(--accent); color: var(--accent); }
|
|
135
|
+
.snapshot-item {
|
|
136
|
+
padding: 8px;
|
|
137
|
+
background: var(--bg-card);
|
|
138
|
+
border-radius: var(--radius);
|
|
139
|
+
margin-bottom: 6px;
|
|
140
|
+
border: 1px solid transparent;
|
|
141
|
+
}
|
|
142
|
+
.snapshot-item.current {
|
|
143
|
+
border-color: var(--accent);
|
|
144
|
+
}
|
|
145
|
+
.snapshot-item.pinned {
|
|
146
|
+
box-shadow: inset 0 0 0 1px rgba(78, 204, 163, 0.35);
|
|
147
|
+
}
|
|
148
|
+
.snapshot-name { font-size: 12px; font-weight: bold; }
|
|
149
|
+
.snapshot-meta { font-size: 10px; color: var(--text-secondary); margin-top: 4px; }
|
|
150
|
+
.snapshot-actions { margin-top: 6px; display: flex; justify-content: flex-end; }
|
|
151
|
+
.snapshot-actions .btn { margin-left: 6px; }
|
|
152
|
+
.snapshot-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
|
153
|
+
.snapshot-tag {
|
|
154
|
+
display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 999px;
|
|
155
|
+
background: rgba(78, 204, 163, 0.12); color: var(--accent); font-size: 10px;
|
|
156
|
+
border: 1px solid transparent; cursor: pointer;
|
|
157
|
+
}
|
|
158
|
+
.snapshot-tag:hover { border-color: rgba(78, 204, 163, 0.45); }
|
|
159
|
+
|
|
127
160
|
/* ===== Graph Canvas ===== */
|
|
128
161
|
#graph-canvas { width: 100%; height: 100%; background: var(--bg-primary); }
|
|
129
162
|
|
|
@@ -194,6 +227,95 @@ body {
|
|
|
194
227
|
display: none;
|
|
195
228
|
}
|
|
196
229
|
.tooltip.visible { display: block; }
|
|
230
|
+
|
|
231
|
+
.graph-empty {
|
|
232
|
+
position: absolute;
|
|
233
|
+
left: 50%;
|
|
234
|
+
top: 50%;
|
|
235
|
+
transform: translate(-50%, -50%);
|
|
236
|
+
color: var(--text-secondary);
|
|
237
|
+
font-size: 13px;
|
|
238
|
+
text-align: center;
|
|
239
|
+
border: 1px dashed var(--border);
|
|
240
|
+
padding: 14px 18px;
|
|
241
|
+
border-radius: var(--radius);
|
|
242
|
+
background: color-mix(in srgb, var(--bg-card) 80%, transparent);
|
|
243
|
+
display: none;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.graph-empty.visible { display: block; }
|
|
247
|
+
|
|
248
|
+
.node-type-item.active {
|
|
249
|
+
background: var(--bg-card);
|
|
250
|
+
border: 1px solid var(--accent);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.report-mermaid,
|
|
254
|
+
.report-viz {
|
|
255
|
+
background: var(--bg-input);
|
|
256
|
+
border: 1px solid var(--border);
|
|
257
|
+
border-radius: var(--radius);
|
|
258
|
+
padding: 12px;
|
|
259
|
+
margin: 10px 0;
|
|
260
|
+
overflow: auto;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.event-log {
|
|
264
|
+
font-size: 11px;
|
|
265
|
+
line-height: 1.8;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.event-log-row {
|
|
269
|
+
border-bottom: 1px solid var(--border);
|
|
270
|
+
padding: 6px 0;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.event-log-toolbar {
|
|
274
|
+
display: flex;
|
|
275
|
+
gap: 6px;
|
|
276
|
+
margin-bottom: 8px;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.event-log-filter.active {
|
|
280
|
+
border-color: var(--accent);
|
|
281
|
+
color: var(--accent);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.event-log-level-info { color: var(--info); }
|
|
285
|
+
.event-log-level-warn { color: var(--warning); }
|
|
286
|
+
.event-log-level-error { color: var(--danger); }
|
|
287
|
+
|
|
288
|
+
.relation-summary {
|
|
289
|
+
display: grid;
|
|
290
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
291
|
+
gap: 8px;
|
|
292
|
+
margin: 10px 0 14px;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.relation-summary .stat-item {
|
|
296
|
+
border: 1px solid var(--border);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.report-toolbar {
|
|
300
|
+
display: flex;
|
|
301
|
+
align-items: center;
|
|
302
|
+
justify-content: space-between;
|
|
303
|
+
gap: 8px;
|
|
304
|
+
margin: 0 auto 12px;
|
|
305
|
+
max-width: 800px;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.report-toolbar-left {
|
|
309
|
+
display: flex;
|
|
310
|
+
gap: 6px;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.report-mode.active {
|
|
314
|
+
background: var(--accent);
|
|
315
|
+
color: #000;
|
|
316
|
+
border-color: var(--accent);
|
|
317
|
+
font-weight: bold;
|
|
318
|
+
}
|
|
197
319
|
</style>
|
|
198
320
|
</head>
|
|
199
321
|
<body>
|
|
@@ -236,6 +358,13 @@ body {
|
|
|
236
358
|
<ul class="node-type-list" id="node-type-list"></ul>
|
|
237
359
|
</div>
|
|
238
360
|
|
|
361
|
+
<div class="sidebar-section" id="snapshot-section" style="display:none">
|
|
362
|
+
<h3>快照</h3>
|
|
363
|
+
<input id="snapshot-search" class="scan-input snapshot-search" placeholder="搜索快照名称或来源" />
|
|
364
|
+
<div class="snapshot-tag-filters" id="snapshot-tag-filters"></div>
|
|
365
|
+
<ul class="snapshot-list" id="snapshot-list"></ul>
|
|
366
|
+
</div>
|
|
367
|
+
|
|
239
368
|
<!-- Risks -->
|
|
240
369
|
<div class="sidebar-section" id="risk-section" style="display:none;flex:1;overflow-y:auto">
|
|
241
370
|
<h3>风险点 <span id="risk-count" style="color:var(--danger)"></span></h3>
|
|
@@ -248,6 +377,7 @@ body {
|
|
|
248
377
|
<!-- Header -->
|
|
249
378
|
<div class="header">
|
|
250
379
|
<div class="perspective-tabs" id="perspective-tabs">
|
|
380
|
+
<div class="perspective-tab" data-view="office" onclick="window.location.href='/index.html'">🏢 3D Office</div>
|
|
251
381
|
<div class="perspective-tab active" data-view="graph">📊 知识图谱</div>
|
|
252
382
|
<div class="perspective-tab" data-perspective="developer">👨💻 开发者</div>
|
|
253
383
|
<div class="perspective-tab" data-perspective="architect">🏗️ 架构师</div>
|
|
@@ -257,6 +387,7 @@ body {
|
|
|
257
387
|
<div class="perspective-tab" data-perspective="executive">📈 管理层</div>
|
|
258
388
|
</div>
|
|
259
389
|
<div class="header-actions">
|
|
390
|
+
<button class="btn btn-icon" onclick="focusOnSelectedNode()" title="聚焦选中节点">🎯</button>
|
|
260
391
|
<button class="btn btn-icon" onclick="toggleTheme()" title="切换主题">🎨</button>
|
|
261
392
|
<button class="btn btn-icon" onclick="togglePanel()" title="详情面板">📋</button>
|
|
262
393
|
</div>
|
|
@@ -287,9 +418,18 @@ body {
|
|
|
287
418
|
|
|
288
419
|
<!-- SVG Graph Canvas -->
|
|
289
420
|
<svg id="graph-canvas"></svg>
|
|
421
|
+
<div class="graph-empty visible" id="graph-empty">暂无图谱数据,先在左侧输入路径并扫描</div>
|
|
290
422
|
|
|
291
423
|
<!-- Report View (hidden by default) -->
|
|
292
424
|
<div id="report-view" style="display:none;padding:24px;overflow-y:auto;height:100%;background:var(--bg-primary)">
|
|
425
|
+
<div class="report-toolbar" id="report-toolbar" style="display:none">
|
|
426
|
+
<div class="report-toolbar-left">
|
|
427
|
+
<button class="btn btn-sm report-mode active" data-mode="markdown">Markdown</button>
|
|
428
|
+
<button class="btn btn-sm report-mode" data-mode="mermaid">Mermaid</button>
|
|
429
|
+
<button class="btn btn-sm report-mode" data-mode="raw">Raw</button>
|
|
430
|
+
</div>
|
|
431
|
+
<button class="btn btn-sm" id="copy-report-btn">复制当前内容</button>
|
|
432
|
+
</div>
|
|
293
433
|
<div id="report-content" style="max-width:800px;margin:0 auto"></div>
|
|
294
434
|
</div>
|
|
295
435
|
</div>
|
|
@@ -324,8 +464,24 @@ let graphData = { nodes: [], edges: [] };
|
|
|
324
464
|
let riskData = [];
|
|
325
465
|
let currentTheme = 'pixel';
|
|
326
466
|
let selectedNode = null;
|
|
327
|
-
let simulation = null; // Force simulation
|
|
328
467
|
let transform = { x: 0, y: 0, scale: 1 };
|
|
468
|
+
let activeTypeFilter = null;
|
|
469
|
+
let wsClient = null;
|
|
470
|
+
let reconnectTimer = null;
|
|
471
|
+
let reportCache = new Map();
|
|
472
|
+
let eventLog = [];
|
|
473
|
+
let currentReport = null;
|
|
474
|
+
let currentReportMode = 'markdown';
|
|
475
|
+
let currentReportPerspective = null;
|
|
476
|
+
let currentLogFilter = 'all';
|
|
477
|
+
let eventLogRenderScheduled = false;
|
|
478
|
+
let graphRenderScheduled = false;
|
|
479
|
+
let latestAgentPayload = null;
|
|
480
|
+
let agentUpdateScheduled = false;
|
|
481
|
+
let snapshotList = [];
|
|
482
|
+
let snapshotQuery = '';
|
|
483
|
+
let activeSnapshotTags = [];
|
|
484
|
+
const MAX_EVENT_LOG_ROWS = 180;
|
|
329
485
|
|
|
330
486
|
// Node type colors
|
|
331
487
|
const TYPE_COLORS = {
|
|
@@ -361,9 +517,14 @@ function startScanFromWelcome() {
|
|
|
361
517
|
}
|
|
362
518
|
|
|
363
519
|
async function doScan(target) {
|
|
520
|
+
reportCache.clear();
|
|
521
|
+
currentReport = null;
|
|
522
|
+
currentReportPerspective = null;
|
|
364
523
|
document.getElementById('welcome').classList.add('hidden');
|
|
365
524
|
document.getElementById('loading').classList.remove('hidden');
|
|
366
525
|
document.getElementById('loading-text').textContent = '正在扫描 ' + target + '...';
|
|
526
|
+
document.getElementById('loading-detail').textContent = '初始化扫描任务...';
|
|
527
|
+
appendOperationLog('开始扫描: ' + target, 'info');
|
|
367
528
|
|
|
368
529
|
try {
|
|
369
530
|
const res = await fetch('/api/studio/scan', {
|
|
@@ -375,6 +536,7 @@ async function doScan(target) {
|
|
|
375
536
|
|
|
376
537
|
if (!res.ok) {
|
|
377
538
|
document.getElementById('loading-text').textContent = '❌ ' + (data.error || '扫描失败');
|
|
539
|
+
appendOperationLog('扫描失败: ' + (data.error || 'unknown error'), 'error');
|
|
378
540
|
setTimeout(() => document.getElementById('loading').classList.add('hidden'), 3000);
|
|
379
541
|
return;
|
|
380
542
|
}
|
|
@@ -383,19 +545,24 @@ async function doScan(target) {
|
|
|
383
545
|
await loadGraph();
|
|
384
546
|
await loadRisks();
|
|
385
547
|
await loadSummary();
|
|
548
|
+
await loadSnapshots();
|
|
386
549
|
|
|
387
550
|
document.getElementById('loading').classList.add('hidden');
|
|
388
551
|
document.getElementById('stats-section').style.display = '';
|
|
389
552
|
document.getElementById('filter-section').style.display = '';
|
|
553
|
+
document.getElementById('snapshot-section').style.display = '';
|
|
390
554
|
document.getElementById('risk-section').style.display = '';
|
|
555
|
+
appendOperationLog('扫描完成,图谱已更新', 'info');
|
|
391
556
|
} catch (err) {
|
|
392
557
|
document.getElementById('loading-text').textContent = '❌ ' + err.message;
|
|
558
|
+
appendOperationLog('扫描异常: ' + err.message, 'error');
|
|
393
559
|
setTimeout(() => document.getElementById('loading').classList.add('hidden'), 3000);
|
|
394
560
|
}
|
|
395
561
|
}
|
|
396
562
|
|
|
397
563
|
async function loadGraph() {
|
|
398
564
|
const res = await fetch('/api/studio/graph');
|
|
565
|
+
if (!res.ok) return;
|
|
399
566
|
const data = await res.json();
|
|
400
567
|
graphData = data;
|
|
401
568
|
renderGraph();
|
|
@@ -404,6 +571,7 @@ async function loadGraph() {
|
|
|
404
571
|
|
|
405
572
|
async function loadRisks() {
|
|
406
573
|
const res = await fetch('/api/studio/risks');
|
|
574
|
+
if (!res.ok) return;
|
|
407
575
|
const data = await res.json();
|
|
408
576
|
riskData = data.risks || [];
|
|
409
577
|
renderRiskList();
|
|
@@ -413,6 +581,7 @@ async function loadRisks() {
|
|
|
413
581
|
|
|
414
582
|
async function loadSummary() {
|
|
415
583
|
const res = await fetch('/api/studio/summary');
|
|
584
|
+
if (!res.ok) return;
|
|
416
585
|
const data = await res.json();
|
|
417
586
|
document.getElementById('project-name').textContent = data.name || '—';
|
|
418
587
|
document.getElementById('project-type').textContent = data.oneLiner || '';
|
|
@@ -425,43 +594,79 @@ async function loadSummary() {
|
|
|
425
594
|
fill.style.background = data.healthScore >= 80 ? '#4ecca3' : data.healthScore >= 60 ? '#f39c12' : '#e94560';
|
|
426
595
|
}
|
|
427
596
|
|
|
597
|
+
async function loadSnapshots() {
|
|
598
|
+
const res = await fetch('/api/studio/snapshots');
|
|
599
|
+
if (!res.ok) return;
|
|
600
|
+
const data = await res.json();
|
|
601
|
+
snapshotList = data.snapshots || [];
|
|
602
|
+
if (activeSnapshotTags.length) {
|
|
603
|
+
const allTags = new Set(snapshotList.flatMap((snapshot) => Array.isArray(snapshot.tags) ? snapshot.tags : []));
|
|
604
|
+
activeSnapshotTags = activeSnapshotTags.filter((tag) => allTags.has(tag));
|
|
605
|
+
}
|
|
606
|
+
renderSnapshots();
|
|
607
|
+
}
|
|
608
|
+
|
|
428
609
|
// ===== Graph Rendering (Force-Directed) =====
|
|
429
610
|
function renderGraph() {
|
|
430
611
|
const svg = document.getElementById('graph-canvas');
|
|
612
|
+
const empty = document.getElementById('graph-empty');
|
|
431
613
|
const { width, height } = svg.getBoundingClientRect();
|
|
432
614
|
svg.innerHTML = '';
|
|
433
615
|
|
|
434
616
|
const nodes = (graphData.nodes || []).filter(n => n.type !== 'file' && n.type !== 'dependency');
|
|
617
|
+
const visibleNodes = activeTypeFilter ? nodes.filter(n => n.type === activeTypeFilter) : nodes;
|
|
618
|
+
const visibleIds = new Set(visibleNodes.map(n => n.id));
|
|
435
619
|
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
436
|
-
const edges = (graphData.edges || []).filter(e =>
|
|
620
|
+
const edges = (graphData.edges || []).filter(e => {
|
|
621
|
+
return nodeMap.has(e.source) && nodeMap.has(e.target) && visibleIds.has(e.source) && visibleIds.has(e.target);
|
|
622
|
+
});
|
|
623
|
+
const selectedAdjacent = new Set();
|
|
624
|
+
if (selectedNode) {
|
|
625
|
+
selectedAdjacent.add(selectedNode);
|
|
626
|
+
for (const e of edges) {
|
|
627
|
+
if (e.source === selectedNode) selectedAdjacent.add(e.target);
|
|
628
|
+
if (e.target === selectedNode) selectedAdjacent.add(e.source);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
437
631
|
|
|
438
|
-
if (
|
|
632
|
+
if (visibleNodes.length === 0) {
|
|
633
|
+
if (empty) {
|
|
634
|
+
empty.classList.add('visible');
|
|
635
|
+
empty.textContent = activeTypeFilter
|
|
636
|
+
? '当前类型筛选下无可展示节点'
|
|
637
|
+
: '暂无图谱数据,先在左侧输入路径并扫描';
|
|
638
|
+
}
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (empty) empty.classList.remove('visible');
|
|
439
642
|
|
|
440
643
|
// Initialize positions (circular layout)
|
|
441
|
-
|
|
442
|
-
const angle = (i /
|
|
644
|
+
visibleNodes.forEach((n, i) => {
|
|
645
|
+
const angle = (i / visibleNodes.length) * 2 * Math.PI;
|
|
443
646
|
const r = Math.min(width, height) * 0.35;
|
|
444
|
-
n._x
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
647
|
+
if (typeof n._x !== 'number' || typeof n._y !== 'number') {
|
|
648
|
+
n._x = width / 2 + Math.cos(angle) * r;
|
|
649
|
+
n._y = height / 2 + Math.sin(angle) * r;
|
|
650
|
+
n._vx = 0;
|
|
651
|
+
n._vy = 0;
|
|
652
|
+
}
|
|
448
653
|
});
|
|
449
654
|
|
|
450
655
|
// Simple force simulation (run synchronously for speed)
|
|
451
656
|
for (let iter = 0; iter < 80; iter++) {
|
|
452
657
|
// Repulsion between all pairs
|
|
453
|
-
for (let i = 0; i <
|
|
454
|
-
for (let j = i + 1; j <
|
|
455
|
-
let dx =
|
|
456
|
-
let dy =
|
|
658
|
+
for (let i = 0; i < visibleNodes.length; i++) {
|
|
659
|
+
for (let j = i + 1; j < visibleNodes.length; j++) {
|
|
660
|
+
let dx = visibleNodes[i]._x - visibleNodes[j]._x;
|
|
661
|
+
let dy = visibleNodes[i]._y - visibleNodes[j]._y;
|
|
457
662
|
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
458
663
|
let force = 800 / (dist * dist);
|
|
459
664
|
let fx = (dx / dist) * force;
|
|
460
665
|
let fy = (dy / dist) * force;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
666
|
+
visibleNodes[i]._vx += fx;
|
|
667
|
+
visibleNodes[i]._vy += fy;
|
|
668
|
+
visibleNodes[j]._vx -= fx;
|
|
669
|
+
visibleNodes[j]._vy -= fy;
|
|
465
670
|
}
|
|
466
671
|
}
|
|
467
672
|
|
|
@@ -483,7 +688,7 @@ function renderGraph() {
|
|
|
483
688
|
}
|
|
484
689
|
|
|
485
690
|
// Center gravity
|
|
486
|
-
for (const n of
|
|
691
|
+
for (const n of visibleNodes) {
|
|
487
692
|
n._vx += (width / 2 - n._x) * 0.001;
|
|
488
693
|
n._vy += (height / 2 - n._y) * 0.001;
|
|
489
694
|
n._x += n._vx * 0.4;
|
|
@@ -499,25 +704,29 @@ function renderGraph() {
|
|
|
499
704
|
// Render SVG
|
|
500
705
|
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
501
706
|
g.setAttribute('id', 'graph-group');
|
|
707
|
+
const edgeDom = [];
|
|
708
|
+
const nodeDom = new Map();
|
|
502
709
|
|
|
503
710
|
// Edges
|
|
504
711
|
for (const e of edges) {
|
|
505
712
|
const s = nodeMap.get(e.source);
|
|
506
713
|
const t = nodeMap.get(e.target);
|
|
507
714
|
if (!s || !t) continue;
|
|
715
|
+
const active = !selectedNode || e.source === selectedNode || e.target === selectedNode;
|
|
508
716
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
509
717
|
line.setAttribute('x1', s._x);
|
|
510
718
|
line.setAttribute('y1', s._y);
|
|
511
719
|
line.setAttribute('x2', t._x);
|
|
512
720
|
line.setAttribute('y2', t._y);
|
|
513
|
-
line.setAttribute('stroke', '#333');
|
|
514
|
-
line.setAttribute('stroke-width', '1');
|
|
515
|
-
line.setAttribute('opacity', '0.
|
|
721
|
+
line.setAttribute('stroke', active ? '#4ecca3' : '#333');
|
|
722
|
+
line.setAttribute('stroke-width', active ? '1.8' : '1');
|
|
723
|
+
line.setAttribute('opacity', active ? '0.75' : '0.15');
|
|
516
724
|
g.appendChild(line);
|
|
725
|
+
edgeDom.push({ line, edge: e });
|
|
517
726
|
}
|
|
518
727
|
|
|
519
728
|
// Nodes
|
|
520
|
-
for (const n of
|
|
729
|
+
for (const n of visibleNodes) {
|
|
521
730
|
const color = TYPE_COLORS[n.type] || TYPE_COLORS.unknown;
|
|
522
731
|
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
523
732
|
group.setAttribute('transform', `translate(${n._x}, ${n._y})`);
|
|
@@ -530,7 +739,8 @@ function renderGraph() {
|
|
|
530
739
|
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
531
740
|
circle.setAttribute('r', n.type === 'module' ? 14 : n.type === 'api' || n.type === 'model' ? 10 : 7);
|
|
532
741
|
circle.setAttribute('fill', color);
|
|
533
|
-
|
|
742
|
+
const isDimmed = selectedNode && !selectedAdjacent.has(n.id);
|
|
743
|
+
circle.setAttribute('opacity', isDimmed ? '0.25' : '0.9');
|
|
534
744
|
circle.setAttribute('stroke', selectedNode === n.id ? '#fff' : 'none');
|
|
535
745
|
circle.setAttribute('stroke-width', '2');
|
|
536
746
|
group.appendChild(circle);
|
|
@@ -542,15 +752,46 @@ function renderGraph() {
|
|
|
542
752
|
text.setAttribute('fill', '#ccc');
|
|
543
753
|
text.setAttribute('font-size', n.type === 'module' ? '11' : '9');
|
|
544
754
|
text.setAttribute('font-family', 'Courier New, monospace');
|
|
755
|
+
text.setAttribute('opacity', isDimmed ? '0.3' : '1');
|
|
545
756
|
const label = n.label.length > 20 ? n.label.slice(0, 18) + '..' : n.label;
|
|
546
757
|
text.textContent = label;
|
|
547
758
|
group.appendChild(text);
|
|
548
759
|
|
|
760
|
+
nodeDom.set(n.id, { group, circle, node: n });
|
|
549
761
|
g.appendChild(group);
|
|
550
762
|
}
|
|
551
763
|
|
|
552
764
|
svg.appendChild(g);
|
|
553
765
|
|
|
766
|
+
const updateEdgeAndNodePositions = () => {
|
|
767
|
+
for (const e of edges) {
|
|
768
|
+
const line = edgeDom.find(item => item.edge === e)?.line;
|
|
769
|
+
if (!line) continue;
|
|
770
|
+
const s = nodeMap.get(e.source);
|
|
771
|
+
const t = nodeMap.get(e.target);
|
|
772
|
+
if (!s || !t) continue;
|
|
773
|
+
line.setAttribute('x1', String(s._x));
|
|
774
|
+
line.setAttribute('y1', String(s._y));
|
|
775
|
+
line.setAttribute('x2', String(t._x));
|
|
776
|
+
line.setAttribute('y2', String(t._y));
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
for (const item of nodeDom.values()) {
|
|
780
|
+
item.group.setAttribute('transform', `translate(${item.node._x}, ${item.node._y})`);
|
|
781
|
+
item.circle.setAttribute('stroke', selectedNode === item.node.id ? '#fff' : 'none');
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
// Drag node in current view
|
|
786
|
+
let draggingNode = null;
|
|
787
|
+
for (const item of nodeDom.values()) {
|
|
788
|
+
item.group.addEventListener('mousedown', (evt) => {
|
|
789
|
+
evt.stopPropagation();
|
|
790
|
+
draggingNode = item;
|
|
791
|
+
item.circle.setAttribute('stroke', '#fff');
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
554
795
|
// Pan & Zoom
|
|
555
796
|
svg.addEventListener('wheel', (e) => {
|
|
556
797
|
e.preventDefault();
|
|
@@ -563,6 +804,19 @@ function renderGraph() {
|
|
|
563
804
|
let dragging = false, lastX, lastY;
|
|
564
805
|
svg.addEventListener('mousedown', (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; });
|
|
565
806
|
svg.addEventListener('mousemove', (e) => {
|
|
807
|
+
if (draggingNode) {
|
|
808
|
+
const pt = svg.createSVGPoint();
|
|
809
|
+
pt.x = e.clientX;
|
|
810
|
+
pt.y = e.clientY;
|
|
811
|
+
const matrix = g.getScreenCTM();
|
|
812
|
+
if (!matrix) return;
|
|
813
|
+
const local = pt.matrixTransform(matrix.inverse());
|
|
814
|
+
draggingNode.node._x = local.x;
|
|
815
|
+
draggingNode.node._y = local.y;
|
|
816
|
+
updateEdgeAndNodePositions();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
566
820
|
if (!dragging) return;
|
|
567
821
|
transform.x += e.clientX - lastX;
|
|
568
822
|
transform.y += e.clientY - lastY;
|
|
@@ -570,8 +824,16 @@ function renderGraph() {
|
|
|
570
824
|
lastY = e.clientY;
|
|
571
825
|
applyTransform();
|
|
572
826
|
});
|
|
573
|
-
svg.addEventListener('mouseup', () => { dragging = false; });
|
|
574
|
-
svg.addEventListener('mouseleave', () => { dragging = false; });
|
|
827
|
+
svg.addEventListener('mouseup', () => { dragging = false; draggingNode = null; });
|
|
828
|
+
svg.addEventListener('mouseleave', () => { dragging = false; draggingNode = null; });
|
|
829
|
+
svg.addEventListener('click', (e) => {
|
|
830
|
+
if (e.target === svg || e.target.id === 'graph-group') {
|
|
831
|
+
selectedNode = null;
|
|
832
|
+
scheduleGraphRender();
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
applyTransform();
|
|
575
837
|
}
|
|
576
838
|
|
|
577
839
|
function applyTransform() {
|
|
@@ -579,6 +841,46 @@ function applyTransform() {
|
|
|
579
841
|
if (g) g.setAttribute('transform', `translate(${transform.x},${transform.y}) scale(${transform.scale})`);
|
|
580
842
|
}
|
|
581
843
|
|
|
844
|
+
function focusOnSelectedNode() {
|
|
845
|
+
if (!selectedNode) {
|
|
846
|
+
appendOperationLog('当前没有选中节点,无法聚焦', 'warn');
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const node = (graphData.nodes || []).find((item) => item.id === selectedNode);
|
|
851
|
+
const svg = document.getElementById('graph-canvas');
|
|
852
|
+
if (!node || !svg || typeof node._x !== 'number' || typeof node._y !== 'number') {
|
|
853
|
+
appendOperationLog('选中节点尚未渲染,无法聚焦', 'warn');
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const { width, height } = svg.getBoundingClientRect();
|
|
858
|
+
transform.scale = Math.max(1.25, transform.scale);
|
|
859
|
+
transform.x = width / 2 - node._x * transform.scale;
|
|
860
|
+
transform.y = height / 2 - node._y * transform.scale;
|
|
861
|
+
applyTransform();
|
|
862
|
+
appendOperationLog(`已聚焦节点: ${node.label || node.id}`, 'info');
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function scheduleGraphRender() {
|
|
866
|
+
if (graphRenderScheduled) return;
|
|
867
|
+
graphRenderScheduled = true;
|
|
868
|
+
requestAnimationFrame(() => {
|
|
869
|
+
graphRenderScheduled = false;
|
|
870
|
+
renderGraph();
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function scheduleAgentRefresh(payload) {
|
|
875
|
+
latestAgentPayload = payload;
|
|
876
|
+
if (agentUpdateScheduled) return;
|
|
877
|
+
agentUpdateScheduled = true;
|
|
878
|
+
requestAnimationFrame(() => {
|
|
879
|
+
agentUpdateScheduled = false;
|
|
880
|
+
updateAgents(latestAgentPayload);
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
582
884
|
// ===== Sidebar Renderers =====
|
|
583
885
|
function renderNodeTypeFilter() {
|
|
584
886
|
const list = document.getElementById('node-type-list');
|
|
@@ -591,11 +893,19 @@ function renderNodeTypeFilter() {
|
|
|
591
893
|
.sort((a, b) => b[1] - a[1])
|
|
592
894
|
.map(([type, count]) => `
|
|
593
895
|
<li class="node-type-item" onclick="filterByType('${type}')">
|
|
896
|
+
<span style="display:none" class="node-type-flag">${activeTypeFilter === type ? 'active' : ''}</span>
|
|
594
897
|
<div class="node-type-dot" style="background:${TYPE_COLORS[type] || '#555'}"></div>
|
|
595
898
|
<span>${TYPE_LABELS[type] || type}</span>
|
|
596
899
|
<span class="node-type-count">${count}</span>
|
|
597
900
|
</li>
|
|
598
901
|
`).join('');
|
|
902
|
+
|
|
903
|
+
list.querySelectorAll('.node-type-item').forEach((el) => {
|
|
904
|
+
const flag = el.querySelector('.node-type-flag');
|
|
905
|
+
if (flag && flag.textContent === 'active') {
|
|
906
|
+
el.classList.add('active');
|
|
907
|
+
}
|
|
908
|
+
});
|
|
599
909
|
}
|
|
600
910
|
|
|
601
911
|
function renderRiskList() {
|
|
@@ -608,6 +918,218 @@ function renderRiskList() {
|
|
|
608
918
|
`).join('');
|
|
609
919
|
}
|
|
610
920
|
|
|
921
|
+
function renderSnapshots() {
|
|
922
|
+
const list = document.getElementById('snapshot-list');
|
|
923
|
+
const tagFilters = document.getElementById('snapshot-tag-filters');
|
|
924
|
+
if (!list) return;
|
|
925
|
+
|
|
926
|
+
const normalizedQuery = snapshotQuery.trim().toLowerCase();
|
|
927
|
+
const allTags = [...new Set(snapshotList.flatMap((snapshot) => Array.isArray(snapshot.tags) ? snapshot.tags : []))].sort();
|
|
928
|
+
if (tagFilters) {
|
|
929
|
+
const hasTags = allTags.length > 0;
|
|
930
|
+
tagFilters.innerHTML = hasTags
|
|
931
|
+
? [`<button class="snapshot-tag-chip ${activeSnapshotTags.length ? '' : 'active'}" data-tag="" data-role="snapshot-filter">全部</button>`]
|
|
932
|
+
.concat(allTags.map((tag) => `<button class="snapshot-tag-chip ${activeSnapshotTags.includes(tag) ? 'active' : ''}" data-tag="${escapeHtml(tag)}" data-role="snapshot-filter">#${escapeHtml(tag)}</button>`))
|
|
933
|
+
.join('')
|
|
934
|
+
: '';
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const visibleSnapshots = snapshotList.filter((snapshot) => {
|
|
938
|
+
const tags = Array.isArray(snapshot.tags) ? snapshot.tags : [];
|
|
939
|
+
const matchesQuery = !normalizedQuery
|
|
940
|
+
|| (snapshot.name || '').toLowerCase().includes(normalizedQuery)
|
|
941
|
+
|| (snapshot.source || '').toLowerCase().includes(normalizedQuery)
|
|
942
|
+
|| tags.some((tag) => tag.toLowerCase().includes(normalizedQuery));
|
|
943
|
+
const matchesTag = !activeSnapshotTags.length || activeSnapshotTags.every((tag) => tags.includes(tag));
|
|
944
|
+
return matchesQuery && matchesTag;
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
if (!visibleSnapshots.length) {
|
|
948
|
+
list.innerHTML = '<li class="snapshot-item"><div class="snapshot-meta">暂无快照</div></li>';
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
list.innerHTML = visibleSnapshots.slice(0, 8).map((snapshot) => {
|
|
953
|
+
const date = snapshot.scanTime ? new Date(snapshot.scanTime).toLocaleString('zh-CN') : 'unknown';
|
|
954
|
+
return `
|
|
955
|
+
<li class="snapshot-item ${snapshot.current ? 'current' : ''} ${snapshot.pinned ? 'pinned' : ''}">
|
|
956
|
+
<div class="snapshot-name">${escapeHtml(snapshot.name || 'unknown')}</div>
|
|
957
|
+
<div class="snapshot-meta">${escapeHtml(snapshot.source || '')}</div>
|
|
958
|
+
<div class="snapshot-meta">${snapshot.pinned ? '📌 ' : ''}${date} · 节点 ${snapshot.nodeCount} · 风险 ${snapshot.riskCount}</div>
|
|
959
|
+
${(snapshot.tags || []).length ? `<div class="snapshot-tags">${snapshot.tags.map((tag) => `<button class="snapshot-tag" data-role="snapshot-filter" data-tag="${escapeHtml(tag)}">#${escapeHtml(tag)}</button>`).join('')}</div>` : ''}
|
|
960
|
+
<div class="snapshot-actions">
|
|
961
|
+
<button class="btn btn-sm" onclick="togglePinSnapshot('${snapshot.id}', ${snapshot.pinned ? 'false' : 'true'})">${snapshot.pinned ? '取消置顶' : '置顶'}</button>
|
|
962
|
+
<button class="btn btn-sm" onclick="editSnapshotTags('${snapshot.id}')">标签</button>
|
|
963
|
+
<button class="btn btn-sm" onclick="renameSnapshot('${snapshot.id}')">重命名</button>
|
|
964
|
+
<button class="btn btn-sm" onclick="restoreSnapshot('${snapshot.id}')">恢复</button>
|
|
965
|
+
<button class="btn btn-sm" onclick="deleteSnapshot('${snapshot.id}')">删除</button>
|
|
966
|
+
</div>
|
|
967
|
+
</li>
|
|
968
|
+
`;
|
|
969
|
+
}).join('');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function setSnapshotTagFilter(tag) {
|
|
973
|
+
const next = (tag || '').trim();
|
|
974
|
+
if (!next) {
|
|
975
|
+
activeSnapshotTags = [];
|
|
976
|
+
renderSnapshots();
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (activeSnapshotTags.includes(next)) {
|
|
981
|
+
activeSnapshotTags = activeSnapshotTags.filter((item) => item !== next);
|
|
982
|
+
} else {
|
|
983
|
+
activeSnapshotTags = [...activeSnapshotTags, next];
|
|
984
|
+
}
|
|
985
|
+
renderSnapshots();
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async function renameSnapshot(snapshotId) {
|
|
989
|
+
const snapshot = snapshotList.find((item) => item.id === snapshotId);
|
|
990
|
+
if (!snapshot) return;
|
|
991
|
+
const name = window.prompt('输入新的快照名称', snapshot.name || '');
|
|
992
|
+
if (!name || !name.trim() || name.trim() === snapshot.name) return;
|
|
993
|
+
|
|
994
|
+
try {
|
|
995
|
+
const res = await fetch('/api/studio/snapshots/' + encodeURIComponent(snapshotId) + '/rename', {
|
|
996
|
+
method: 'POST',
|
|
997
|
+
headers: { 'Content-Type': 'application/json' },
|
|
998
|
+
body: JSON.stringify({ name: name.trim() }),
|
|
999
|
+
});
|
|
1000
|
+
const data = await res.json();
|
|
1001
|
+
if (!res.ok) {
|
|
1002
|
+
throw new Error(data.error || 'rename failed');
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
snapshotList = data.snapshots || [];
|
|
1006
|
+
renderSnapshots();
|
|
1007
|
+
appendOperationLog(`已重命名快照: ${name.trim()}`, 'info');
|
|
1008
|
+
} catch (err) {
|
|
1009
|
+
appendOperationLog(`重命名快照失败: ${err.message}`, 'error');
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function editSnapshotTags(snapshotId) {
|
|
1014
|
+
const snapshot = snapshotList.find((item) => item.id === snapshotId);
|
|
1015
|
+
if (!snapshot) return;
|
|
1016
|
+
|
|
1017
|
+
const value = window.prompt('输入快照标签,使用英文逗号分隔', (snapshot.tags || []).join(', '));
|
|
1018
|
+
if (value === null) return;
|
|
1019
|
+
|
|
1020
|
+
const tags = value.split(',').map((tag) => tag.trim()).filter(Boolean);
|
|
1021
|
+
|
|
1022
|
+
try {
|
|
1023
|
+
const res = await fetch('/api/studio/snapshots/' + encodeURIComponent(snapshotId) + '/tags', {
|
|
1024
|
+
method: 'POST',
|
|
1025
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1026
|
+
body: JSON.stringify({ tags }),
|
|
1027
|
+
});
|
|
1028
|
+
const data = await res.json();
|
|
1029
|
+
if (!res.ok) {
|
|
1030
|
+
throw new Error(data.error || 'update tags failed');
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
snapshotList = data.snapshots || [];
|
|
1034
|
+
if (activeSnapshotTags.length) {
|
|
1035
|
+
const allTags = new Set(snapshotList.flatMap((item) => Array.isArray(item.tags) ? item.tags : []));
|
|
1036
|
+
activeSnapshotTags = activeSnapshotTags.filter((tag) => allTags.has(tag));
|
|
1037
|
+
}
|
|
1038
|
+
renderSnapshots();
|
|
1039
|
+
appendOperationLog('快照标签已更新', 'info');
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
appendOperationLog(`更新快照标签失败: ${err.message}`, 'error');
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async function togglePinSnapshot(snapshotId, pinned) {
|
|
1046
|
+
try {
|
|
1047
|
+
const res = await fetch('/api/studio/snapshots/' + encodeURIComponent(snapshotId) + '/pin', {
|
|
1048
|
+
method: 'POST',
|
|
1049
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1050
|
+
body: JSON.stringify({ pinned }),
|
|
1051
|
+
});
|
|
1052
|
+
const data = await res.json();
|
|
1053
|
+
if (!res.ok) {
|
|
1054
|
+
throw new Error(data.error || 'pin failed');
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
snapshotList = data.snapshots || [];
|
|
1058
|
+
renderSnapshots();
|
|
1059
|
+
appendOperationLog(pinned ? '快照已置顶' : '快照已取消置顶', 'info');
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
appendOperationLog(`更新快照置顶失败: ${err.message}`, 'error');
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function restoreSnapshot(snapshotId) {
|
|
1066
|
+
try {
|
|
1067
|
+
const res = await fetch('/api/studio/snapshots/' + encodeURIComponent(snapshotId) + '/load', { method: 'POST' });
|
|
1068
|
+
const data = await res.json();
|
|
1069
|
+
if (!res.ok) {
|
|
1070
|
+
throw new Error(data.error || 'restore failed');
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
selectedNode = null;
|
|
1074
|
+
await Promise.all([loadGraph(), loadRisks(), loadSummary(), loadSnapshots()]);
|
|
1075
|
+
document.getElementById('stats-section').style.display = '';
|
|
1076
|
+
document.getElementById('filter-section').style.display = '';
|
|
1077
|
+
document.getElementById('snapshot-section').style.display = '';
|
|
1078
|
+
document.getElementById('risk-section').style.display = '';
|
|
1079
|
+
appendOperationLog(`已恢复快照: ${data.source || snapshotId}`, 'info');
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
appendOperationLog(`恢复快照失败: ${err.message}`, 'error');
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async function deleteSnapshot(snapshotId) {
|
|
1086
|
+
const snapshot = snapshotList.find((item) => item.id === snapshotId);
|
|
1087
|
+
if (!snapshot) return;
|
|
1088
|
+
const confirmed = window.confirm(`确认删除快照“${snapshot.name || snapshot.id}”?`);
|
|
1089
|
+
if (!confirmed) return;
|
|
1090
|
+
|
|
1091
|
+
try {
|
|
1092
|
+
const res = await fetch('/api/studio/snapshots/' + encodeURIComponent(snapshotId) + '/delete', { method: 'POST' });
|
|
1093
|
+
const data = await res.json();
|
|
1094
|
+
if (!res.ok) {
|
|
1095
|
+
throw new Error(data.error || 'delete failed');
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
snapshotList = data.snapshots || [];
|
|
1099
|
+
renderSnapshots();
|
|
1100
|
+
if (data.hasCurrent) {
|
|
1101
|
+
selectedNode = null;
|
|
1102
|
+
await Promise.all([loadGraph(), loadRisks(), loadSummary(), loadSnapshots()]);
|
|
1103
|
+
} else {
|
|
1104
|
+
clearStudioState();
|
|
1105
|
+
}
|
|
1106
|
+
appendOperationLog(`已删除快照: ${snapshot.name || snapshot.id}`, 'info');
|
|
1107
|
+
} catch (err) {
|
|
1108
|
+
appendOperationLog(`删除快照失败: ${err.message}`, 'error');
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function clearStudioState() {
|
|
1113
|
+
graphData = { nodes: [], edges: [] };
|
|
1114
|
+
riskData = [];
|
|
1115
|
+
selectedNode = null;
|
|
1116
|
+
snapshotList = [];
|
|
1117
|
+
activeSnapshotTags = [];
|
|
1118
|
+
document.getElementById('project-name').textContent = '';
|
|
1119
|
+
document.getElementById('project-type').textContent = '';
|
|
1120
|
+
document.getElementById('stat-apis').textContent = '0';
|
|
1121
|
+
document.getElementById('stat-models').textContent = '0';
|
|
1122
|
+
document.getElementById('stat-files').textContent = '0';
|
|
1123
|
+
document.getElementById('stat-risks').textContent = '0';
|
|
1124
|
+
document.getElementById('risk-count').textContent = '(0)';
|
|
1125
|
+
document.getElementById('health-score').textContent = '—';
|
|
1126
|
+
document.getElementById('health-fill').style.width = '0%';
|
|
1127
|
+
renderGraph();
|
|
1128
|
+
renderNodeTypeFilter();
|
|
1129
|
+
renderRiskList();
|
|
1130
|
+
renderSnapshots();
|
|
1131
|
+
}
|
|
1132
|
+
|
|
611
1133
|
// ===== Node Detail Panel =====
|
|
612
1134
|
async function showNodeDetail(node) {
|
|
613
1135
|
selectedNode = node.id;
|
|
@@ -619,13 +1141,30 @@ async function showNodeDetail(node) {
|
|
|
619
1141
|
|
|
620
1142
|
// Fetch node detail
|
|
621
1143
|
const res = await fetch('/api/studio/node/' + encodeURIComponent(node.id));
|
|
1144
|
+
if (!res.ok) {
|
|
1145
|
+
document.getElementById('panel-body').innerHTML = '<p>节点详情加载失败</p>';
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
622
1148
|
const data = await res.json();
|
|
1149
|
+
const outgoingCount = data.outgoing?.length || 0;
|
|
1150
|
+
const incomingCount = data.incoming?.length || 0;
|
|
1151
|
+
const neighborCount = data.neighbors?.length || 0;
|
|
1152
|
+
const degree = outgoingCount + incomingCount;
|
|
623
1153
|
|
|
624
1154
|
let html = `<p><b>类型:</b> ${TYPE_LABELS[node.type] || node.type}</p>`;
|
|
625
1155
|
if (node.filePath) html += `<p><b>文件:</b> ${escapeHtml(node.filePath)}</p>`;
|
|
626
1156
|
if (node.language) html += `<p><b>语言:</b> ${node.language}</p>`;
|
|
627
1157
|
if (node.module) html += `<p><b>模块:</b> ${node.module}</p>`;
|
|
628
1158
|
|
|
1159
|
+
html += `
|
|
1160
|
+
<div class="relation-summary">
|
|
1161
|
+
<div class="stat-item"><div class="stat-value">${outgoingCount}</div><div class="stat-label">输出</div></div>
|
|
1162
|
+
<div class="stat-item"><div class="stat-value">${incomingCount}</div><div class="stat-label">输入</div></div>
|
|
1163
|
+
<div class="stat-item"><div class="stat-value">${neighborCount}</div><div class="stat-label">邻居</div></div>
|
|
1164
|
+
<div class="stat-item"><div class="stat-value">${degree}</div><div class="stat-label">总关系</div></div>
|
|
1165
|
+
</div>
|
|
1166
|
+
`;
|
|
1167
|
+
|
|
629
1168
|
if (data.outgoing?.length > 0) {
|
|
630
1169
|
html += `<h3>输出关系 (${data.outgoing.length})</h3><ul>`;
|
|
631
1170
|
data.outgoing.slice(0, 15).forEach(e => {
|
|
@@ -693,33 +1232,60 @@ document.getElementById('perspective-tabs').addEventListener('click', async (e)
|
|
|
693
1232
|
if (view === 'graph') {
|
|
694
1233
|
document.getElementById('graph-canvas').style.display = '';
|
|
695
1234
|
document.getElementById('report-view').style.display = 'none';
|
|
1235
|
+
document.getElementById('report-toolbar').style.display = 'none';
|
|
696
1236
|
renderGraph();
|
|
697
1237
|
return;
|
|
698
1238
|
}
|
|
699
1239
|
|
|
700
|
-
if (!graphData.nodes?.length)
|
|
1240
|
+
if (!graphData.nodes?.length) {
|
|
1241
|
+
document.getElementById('report-content').innerHTML = '<div class="loading-text">暂无图谱数据,请先扫描项目</div>';
|
|
1242
|
+
document.getElementById('graph-canvas').style.display = 'none';
|
|
1243
|
+
document.getElementById('report-view').style.display = 'block';
|
|
1244
|
+
document.getElementById('report-toolbar').style.display = 'none';
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
701
1247
|
|
|
702
1248
|
// Show report view
|
|
703
1249
|
document.getElementById('graph-canvas').style.display = 'none';
|
|
704
1250
|
document.getElementById('report-view').style.display = 'block';
|
|
1251
|
+
document.getElementById('report-toolbar').style.display = 'none';
|
|
705
1252
|
document.getElementById('report-content').innerHTML = '<div class="loading-text">生成报告中...</div>';
|
|
706
1253
|
|
|
707
|
-
|
|
708
|
-
|
|
1254
|
+
try {
|
|
1255
|
+
const report = await fetchPerspectiveReport(perspective);
|
|
1256
|
+
currentReport = report;
|
|
1257
|
+
currentReportMode = 'markdown';
|
|
1258
|
+
currentReportPerspective = perspective;
|
|
1259
|
+
renderCurrentReport();
|
|
1260
|
+
document.getElementById('report-toolbar').style.display = '';
|
|
1261
|
+
appendOperationLog(`报告已生成: ${perspective}`, 'info');
|
|
1262
|
+
} catch (err) {
|
|
1263
|
+
document.getElementById('report-content').innerHTML = renderReportError(err.message || 'unknown');
|
|
1264
|
+
document.getElementById('report-toolbar').style.display = 'none';
|
|
1265
|
+
appendOperationLog(`报告生成失败: ${err.message || 'unknown'}`, 'error');
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
709
1268
|
|
|
710
|
-
|
|
711
|
-
|
|
1269
|
+
document.getElementById('report-toolbar').addEventListener('click', async (e) => {
|
|
1270
|
+
const modeBtn = e.target.closest('.report-mode');
|
|
1271
|
+
if (modeBtn) {
|
|
1272
|
+
currentReportMode = modeBtn.dataset.mode || 'markdown';
|
|
1273
|
+
document.querySelectorAll('.report-mode').forEach((btn) => btn.classList.remove('active'));
|
|
1274
|
+
modeBtn.classList.add('active');
|
|
1275
|
+
renderCurrentReport();
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
712
1278
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1279
|
+
if (e.target.id === 'copy-report-btn') {
|
|
1280
|
+
const text = getCurrentReportText();
|
|
1281
|
+
if (!text) return;
|
|
1282
|
+
try {
|
|
1283
|
+
await navigator.clipboard.writeText(text);
|
|
1284
|
+
appendOperationLog('报告内容已复制到剪贴板', 'info');
|
|
1285
|
+
} catch {
|
|
1286
|
+
appendOperationLog('复制失败,请检查浏览器权限', 'warn');
|
|
718
1287
|
}
|
|
719
1288
|
}
|
|
720
|
-
|
|
721
|
-
html += `<p style="color:var(--text-muted);margin-top:24px;font-size:11px">生成时间: ${report.generatedAt || new Date().toISOString()}</p>`;
|
|
722
|
-
document.getElementById('report-content').innerHTML = html;
|
|
723
1289
|
});
|
|
724
1290
|
|
|
725
1291
|
// ===== Theme Toggle =====
|
|
@@ -733,7 +1299,9 @@ function togglePanel() {
|
|
|
733
1299
|
}
|
|
734
1300
|
|
|
735
1301
|
function filterByType(type) {
|
|
736
|
-
|
|
1302
|
+
activeTypeFilter = activeTypeFilter === type ? null : type;
|
|
1303
|
+
renderNodeTypeFilter();
|
|
1304
|
+
renderGraph();
|
|
737
1305
|
}
|
|
738
1306
|
|
|
739
1307
|
// ===== Tooltip =====
|
|
@@ -755,23 +1323,51 @@ function hideTooltip() {
|
|
|
755
1323
|
// ===== WebSocket for live updates =====
|
|
756
1324
|
function connectWS() {
|
|
757
1325
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
758
|
-
|
|
1326
|
+
wsClient = new WebSocket(protocol + '//' + location.host + '/ws');
|
|
1327
|
+
|
|
1328
|
+
wsClient.onopen = async () => {
|
|
1329
|
+
appendOperationLog('WebSocket 已连接', 'info');
|
|
1330
|
+
await rehydrateStudioState();
|
|
1331
|
+
};
|
|
759
1332
|
|
|
760
|
-
|
|
1333
|
+
wsClient.onmessage = (e) => {
|
|
761
1334
|
try {
|
|
762
1335
|
const msg = JSON.parse(e.data);
|
|
763
|
-
if (msg.type === 'agent:update')
|
|
764
|
-
else if (msg.type === 'graph:update') {
|
|
1336
|
+
if (msg.type === 'agent:update') scheduleAgentRefresh(msg.payload);
|
|
1337
|
+
else if (msg.type === 'graph:update') {
|
|
1338
|
+
graphData = { ...graphData, ...msg.payload };
|
|
1339
|
+
scheduleGraphRender();
|
|
1340
|
+
}
|
|
765
1341
|
else if (msg.type === 'scan:progress') {
|
|
766
|
-
|
|
1342
|
+
const detail = msg.payload.detail || '';
|
|
1343
|
+
const percent = Number.isFinite(msg.payload.percent) ? Math.round(msg.payload.percent) : 0;
|
|
1344
|
+
document.getElementById('loading-detail').textContent = `${detail} (${percent}%)`;
|
|
1345
|
+
appendOperationLog(`扫描进度 ${percent}% - ${detail || msg.payload.phase || ''}`, 'info');
|
|
767
1346
|
}
|
|
768
1347
|
else if (msg.type === 'log') {
|
|
769
|
-
|
|
1348
|
+
appendOperationLog(msg.payload.message, msg.payload.level || 'info');
|
|
1349
|
+
}
|
|
1350
|
+
else if (msg.type === 'files:generated') {
|
|
1351
|
+
appendOperationLog(`已生成 ${Array.isArray(msg.payload) ? msg.payload.length : 0} 个测试文件`, 'info');
|
|
1352
|
+
}
|
|
1353
|
+
else if (msg.type === 'pipeline:complete') {
|
|
1354
|
+
const ok = msg.payload?.status === 'success';
|
|
1355
|
+
appendOperationLog(ok ? 'Pipeline 执行完成' : `Pipeline 执行失败: ${msg.payload?.error || 'unknown'}`, ok ? 'info' : 'error');
|
|
1356
|
+
}
|
|
1357
|
+
else if (msg.type === 'test:complete') {
|
|
1358
|
+
const metrics = msg.payload?.metrics;
|
|
1359
|
+
if (metrics) {
|
|
1360
|
+
appendOperationLog(`测试完成: pass ${metrics.passed}, fail ${metrics.failed}, skipped ${metrics.skipped}, timeout ${metrics.timedOut}`, 'info');
|
|
1361
|
+
}
|
|
770
1362
|
}
|
|
771
1363
|
} catch {}
|
|
772
1364
|
};
|
|
773
1365
|
|
|
774
|
-
|
|
1366
|
+
wsClient.onclose = () => {
|
|
1367
|
+
appendOperationLog('WebSocket 已断开,3 秒后重连', 'warn');
|
|
1368
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
1369
|
+
reconnectTimer = setTimeout(connectWS, 3000);
|
|
1370
|
+
};
|
|
775
1371
|
}
|
|
776
1372
|
|
|
777
1373
|
function updateAgents(agents) {
|
|
@@ -797,7 +1393,251 @@ function markdownToHtml(md) {
|
|
|
797
1393
|
return html;
|
|
798
1394
|
}
|
|
799
1395
|
|
|
1396
|
+
function renderVisualization(viz) {
|
|
1397
|
+
if (!viz || !viz.data) return '';
|
|
1398
|
+
if (viz.type === 'mermaid') {
|
|
1399
|
+
const encoded = encodeURIComponent(viz.data);
|
|
1400
|
+
return `<div class="report-mermaid" data-graph="${encoded}"><pre class="mermaid">${escapeHtml(viz.data)}</pre></div>`;
|
|
1401
|
+
}
|
|
1402
|
+
return `<div class="report-viz"><pre>${escapeHtml(viz.data)}</pre></div>`;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
async function fetchPerspectiveReport(perspective) {
|
|
1406
|
+
const cached = reportCache.get(perspective);
|
|
1407
|
+
if (cached) return cached;
|
|
1408
|
+
const res = await fetch('/api/studio/report/' + perspective);
|
|
1409
|
+
if (!res.ok) {
|
|
1410
|
+
throw new Error('报告生成失败');
|
|
1411
|
+
}
|
|
1412
|
+
const report = await res.json();
|
|
1413
|
+
reportCache.set(perspective, report);
|
|
1414
|
+
return report;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function renderReportError(message) {
|
|
1418
|
+
return `
|
|
1419
|
+
<div class="loading-text">报告生成失败: ${escapeHtml(message)}</div>
|
|
1420
|
+
<div style="margin-top:12px">
|
|
1421
|
+
<button class="btn btn-primary btn-sm" onclick="retryCurrentReport()">重试</button>
|
|
1422
|
+
</div>
|
|
1423
|
+
`;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
async function retryCurrentReport() {
|
|
1427
|
+
if (!currentReportPerspective) {
|
|
1428
|
+
appendOperationLog('当前没有可重试的报告视角', 'warn');
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
reportCache.delete(currentReportPerspective);
|
|
1433
|
+
document.getElementById('report-content').innerHTML = '<div class="loading-text">重试生成报告中...</div>';
|
|
1434
|
+
try {
|
|
1435
|
+
const report = await fetchPerspectiveReport(currentReportPerspective);
|
|
1436
|
+
currentReport = report;
|
|
1437
|
+
currentReportMode = 'markdown';
|
|
1438
|
+
document.querySelectorAll('.report-mode').forEach((btn) => {
|
|
1439
|
+
btn.classList.toggle('active', btn.dataset.mode === 'markdown');
|
|
1440
|
+
});
|
|
1441
|
+
renderCurrentReport();
|
|
1442
|
+
document.getElementById('report-toolbar').style.display = '';
|
|
1443
|
+
appendOperationLog(`报告重试成功: ${currentReportPerspective}`, 'info');
|
|
1444
|
+
} catch (err) {
|
|
1445
|
+
document.getElementById('report-content').innerHTML = renderReportError(err.message || 'unknown');
|
|
1446
|
+
document.getElementById('report-toolbar').style.display = 'none';
|
|
1447
|
+
appendOperationLog(`报告重试失败: ${err.message || 'unknown'}`, 'error');
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function renderCurrentReport() {
|
|
1452
|
+
const container = document.getElementById('report-content');
|
|
1453
|
+
if (!container || !currentReport) return;
|
|
1454
|
+
|
|
1455
|
+
if (currentReportMode === 'raw') {
|
|
1456
|
+
container.innerHTML = `<pre class="report-viz">${escapeHtml(JSON.stringify(currentReport, null, 2))}</pre>`;
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (currentReportMode === 'mermaid') {
|
|
1461
|
+
const mermaidSections = (currentReport.sections || [])
|
|
1462
|
+
.filter((s) => s.visualization?.type === 'mermaid' && s.visualization?.data)
|
|
1463
|
+
.map((s) => {
|
|
1464
|
+
return `<h3 style="color:var(--accent);margin-top:18px">${escapeHtml(s.heading)}</h3>${renderVisualization(s.visualization)}`;
|
|
1465
|
+
});
|
|
1466
|
+
container.innerHTML = mermaidSections.length
|
|
1467
|
+
? mermaidSections.join('')
|
|
1468
|
+
: '<div class="loading-text">当前报告不包含 Mermaid 可视化</div>';
|
|
1469
|
+
hydrateMermaid();
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
let html = `<h1 style="color:var(--accent);margin-bottom:8px">${escapeHtml(currentReport.title || '')}</h1>`;
|
|
1474
|
+
html += `<p style="color:var(--text-secondary);margin-bottom:24px">${escapeHtml(currentReport.summary || '')}</p>`;
|
|
1475
|
+
|
|
1476
|
+
for (const section of (currentReport.sections || [])) {
|
|
1477
|
+
html += `<h2 style="color:var(--accent);margin-top:24px;margin-bottom:8px">${escapeHtml(section.heading)}</h2>`;
|
|
1478
|
+
html += `<div style="line-height:1.8">${markdownToHtml(section.content)}</div>`;
|
|
1479
|
+
if (section.visualization) {
|
|
1480
|
+
html += renderVisualization(section.visualization);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
html += `<p style="color:var(--text-muted);margin-top:24px;font-size:11px">生成时间: ${currentReport.generatedAt || new Date().toISOString()}</p>`;
|
|
1485
|
+
container.innerHTML = html;
|
|
1486
|
+
hydrateMermaid();
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function getCurrentReportText() {
|
|
1490
|
+
if (!currentReport) return '';
|
|
1491
|
+
if (currentReportMode === 'raw') {
|
|
1492
|
+
return JSON.stringify(currentReport, null, 2);
|
|
1493
|
+
}
|
|
1494
|
+
if (currentReportMode === 'mermaid') {
|
|
1495
|
+
const charts = (currentReport.sections || [])
|
|
1496
|
+
.filter((s) => s.visualization?.type === 'mermaid' && s.visualization?.data)
|
|
1497
|
+
.map((s) => `# ${s.heading}\n${s.visualization.data}`);
|
|
1498
|
+
return charts.join('\n\n');
|
|
1499
|
+
}
|
|
1500
|
+
return reportToMarkdown(currentReport);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function reportToMarkdown(report) {
|
|
1504
|
+
let text = `# ${report.title || 'Report'}\n\n`;
|
|
1505
|
+
if (report.summary) {
|
|
1506
|
+
text += `${report.summary}\n\n`;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
for (const section of (report.sections || [])) {
|
|
1510
|
+
text += `## ${section.heading}\n\n${section.content || ''}\n\n`;
|
|
1511
|
+
if (section.visualization?.data) {
|
|
1512
|
+
if (section.visualization.type === 'mermaid') {
|
|
1513
|
+
text += `\`\`\`mermaid\n${section.visualization.data}\n\`\`\`\n\n`;
|
|
1514
|
+
} else {
|
|
1515
|
+
text += `\`\`\`\n${section.visualization.data}\n\`\`\`\n\n`;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
if (report.generatedAt) {
|
|
1521
|
+
text += `> generatedAt: ${report.generatedAt}\n`;
|
|
1522
|
+
}
|
|
1523
|
+
return text;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
async function ensureMermaidReady() {
|
|
1527
|
+
if (window.mermaid) return window.mermaid;
|
|
1528
|
+
if (window.__mermaidLoading) return window.__mermaidLoading;
|
|
1529
|
+
|
|
1530
|
+
window.__mermaidLoading = new Promise((resolve, reject) => {
|
|
1531
|
+
const script = document.createElement('script');
|
|
1532
|
+
script.src = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js';
|
|
1533
|
+
script.onload = () => resolve(window.mermaid);
|
|
1534
|
+
script.onerror = () => reject(new Error('Failed to load Mermaid runtime'));
|
|
1535
|
+
document.head.appendChild(script);
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
return window.__mermaidLoading;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
async function hydrateMermaid() {
|
|
1542
|
+
const blocks = document.querySelectorAll('.mermaid');
|
|
1543
|
+
if (!blocks.length) return;
|
|
1544
|
+
try {
|
|
1545
|
+
const mermaid = await ensureMermaidReady();
|
|
1546
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose', theme: 'default' });
|
|
1547
|
+
await mermaid.run({ querySelector: '.mermaid' });
|
|
1548
|
+
} catch (err) {
|
|
1549
|
+
appendOperationLog('Mermaid 渲染失败,已回退为文本展示', 'warn');
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function appendOperationLog(message, level = 'info') {
|
|
1554
|
+
const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
|
1555
|
+
eventLog.push({ ts, level, message: String(message || '') });
|
|
1556
|
+
if (eventLog.length > MAX_EVENT_LOG_ROWS) {
|
|
1557
|
+
eventLog = eventLog.slice(eventLog.length - MAX_EVENT_LOG_ROWS);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
scheduleEventLogRender();
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function scheduleEventLogRender() {
|
|
1564
|
+
if (eventLogRenderScheduled) return;
|
|
1565
|
+
eventLogRenderScheduled = true;
|
|
1566
|
+
requestAnimationFrame(() => {
|
|
1567
|
+
eventLogRenderScheduled = false;
|
|
1568
|
+
renderOperationLogPanel();
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function renderOperationLogPanel() {
|
|
1573
|
+
const panel = document.getElementById('panel');
|
|
1574
|
+
const title = document.getElementById('panel-title');
|
|
1575
|
+
const body = document.getElementById('panel-body');
|
|
1576
|
+
if (!panel || !title || !body) return;
|
|
1577
|
+
|
|
1578
|
+
if (!panel.classList.contains('open') || title.textContent === '实时日志') {
|
|
1579
|
+
title.textContent = '实时日志';
|
|
1580
|
+
const filtered = eventLog.filter((row) => currentLogFilter === 'all' || row.level === currentLogFilter);
|
|
1581
|
+
const latestRows = filtered.slice(-40);
|
|
1582
|
+
body.innerHTML = `
|
|
1583
|
+
<div class="event-log-toolbar">
|
|
1584
|
+
<button class="btn btn-sm event-log-filter ${currentLogFilter === 'all' ? 'active' : ''}" data-level="all">全部</button>
|
|
1585
|
+
<button class="btn btn-sm event-log-filter ${currentLogFilter === 'info' ? 'active' : ''}" data-level="info">Info</button>
|
|
1586
|
+
<button class="btn btn-sm event-log-filter ${currentLogFilter === 'warn' ? 'active' : ''}" data-level="warn">Warn</button>
|
|
1587
|
+
<button class="btn btn-sm event-log-filter ${currentLogFilter === 'error' ? 'active' : ''}" data-level="error">Error</button>
|
|
1588
|
+
</div>
|
|
1589
|
+
<div class="event-log">
|
|
1590
|
+
` + latestRows.map(row => {
|
|
1591
|
+
return `<div class="event-log-row"><span class="event-log-level-${row.level}">[${row.level}]</span> ${escapeHtml(row.ts)} ${escapeHtml(row.message)}</div>`;
|
|
1592
|
+
}).join('') + '</div>';
|
|
1593
|
+
|
|
1594
|
+
body.querySelectorAll('.event-log-filter').forEach((btn) => {
|
|
1595
|
+
btn.addEventListener('click', () => {
|
|
1596
|
+
currentLogFilter = btn.dataset.level || 'all';
|
|
1597
|
+
renderOperationLogPanel();
|
|
1598
|
+
});
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
async function rehydrateStudioState() {
|
|
1604
|
+
try {
|
|
1605
|
+
await Promise.all([loadGraph(), loadRisks(), loadSummary(), loadSnapshots()]);
|
|
1606
|
+
document.getElementById('stats-section').style.display = '';
|
|
1607
|
+
document.getElementById('filter-section').style.display = '';
|
|
1608
|
+
document.getElementById('snapshot-section').style.display = '';
|
|
1609
|
+
document.getElementById('risk-section').style.display = '';
|
|
1610
|
+
} catch {
|
|
1611
|
+
// Ignore; no scanned project yet.
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
800
1615
|
// ===== Init =====
|
|
1616
|
+
document.getElementById('snapshot-search').addEventListener('input', (e) => {
|
|
1617
|
+
snapshotQuery = e.target.value || '';
|
|
1618
|
+
renderSnapshots();
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
document.getElementById('snapshot-tag-filters').addEventListener('click', (e) => {
|
|
1622
|
+
const target = e.target.closest('[data-role="snapshot-filter"]');
|
|
1623
|
+
if (!target) return;
|
|
1624
|
+
setSnapshotTagFilter(target.dataset.tag || '');
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
document.getElementById('snapshot-list').addEventListener('click', (e) => {
|
|
1628
|
+
const target = e.target.closest('[data-role="snapshot-filter"]');
|
|
1629
|
+
if (!target) return;
|
|
1630
|
+
setSnapshotTagFilter(target.dataset.tag || '');
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
document.addEventListener('keydown', (e) => {
|
|
1634
|
+
if (e.key === 'Escape') {
|
|
1635
|
+
selectedNode = null;
|
|
1636
|
+
hideTooltip();
|
|
1637
|
+
scheduleGraphRender();
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
|
|
801
1641
|
connectWS();
|
|
802
1642
|
</script>
|
|
803
1643
|
</body>
|