clawmatrix 0.1.15 → 0.1.16
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/BOOTSTRAP.md +17 -2
- package/package.json +1 -1
- package/src/auth.ts +42 -12
- package/src/cluster-service.ts +31 -4
- package/src/compat.ts +3 -0
- package/src/connection.ts +15 -6
- package/src/handoff.ts +311 -17
- package/src/http-utils.ts +35 -0
- package/src/index.ts +33 -15
- package/src/model-proxy.ts +19 -16
- package/src/peer-manager.ts +55 -5
- package/src/router.ts +62 -28
- package/src/tool-proxy.ts +22 -7
- package/src/tools/cluster-events.ts +119 -0
- package/src/tools/cluster-exec.ts +4 -0
- package/src/tools/cluster-handoff-reply.ts +77 -0
- package/src/tools/cluster-handoff.ts +12 -0
- package/src/tools/cluster-peers.ts +17 -1
- package/src/tools/cluster-send.ts +1 -3
- package/src/tools/cluster-tool.ts +2 -5
- package/src/types.ts +93 -0
- package/src/web-ui.ts +490 -345
- package/src/web.ts +675 -53
package/src/web-ui.ts
CHANGED
|
@@ -9,6 +9,7 @@ export function renderDashboard(nodeId: string): string {
|
|
|
9
9
|
<style>
|
|
10
10
|
${CSS}
|
|
11
11
|
</style>
|
|
12
|
+
<script src="https://cdn.jsdelivr.net/npm/force-graph@1/dist/force-graph.min.js"></script>
|
|
12
13
|
</head>
|
|
13
14
|
<body>
|
|
14
15
|
|
|
@@ -72,26 +73,32 @@ ${CSS}
|
|
|
72
73
|
|
|
73
74
|
<!-- Main content -->
|
|
74
75
|
<div class="main">
|
|
75
|
-
<!-- Left: Mesh
|
|
76
|
+
<!-- Left: Mesh -->
|
|
76
77
|
<div class="panel-left">
|
|
77
78
|
<div class="card mesh-card">
|
|
78
79
|
<div class="card-header">
|
|
79
80
|
<h2>Mesh Topology</h2>
|
|
80
81
|
<span id="peer-count" class="badge">0 nodes</span>
|
|
81
82
|
</div>
|
|
82
|
-
<
|
|
83
|
-
</div>
|
|
84
|
-
<div id="node-detail" class="card detail-card hidden">
|
|
85
|
-
<div class="card-header">
|
|
86
|
-
<h2 id="detail-title">Node Details</h2>
|
|
87
|
-
<span id="detail-status" class="badge"></span>
|
|
88
|
-
</div>
|
|
89
|
-
<div id="detail-body" class="detail-body"></div>
|
|
83
|
+
<div id="mesh-container"></div>
|
|
90
84
|
</div>
|
|
91
85
|
</div>
|
|
92
86
|
|
|
93
|
-
<!-- Right: Chat -->
|
|
87
|
+
<!-- Right: Detail + Chat -->
|
|
94
88
|
<div class="panel-right">
|
|
89
|
+
<div id="node-detail" class="detail-panel hidden">
|
|
90
|
+
<div class="detail-header">
|
|
91
|
+
<div class="detail-header-left">
|
|
92
|
+
<span id="detail-status-dot" class="detail-dot"></span>
|
|
93
|
+
<span id="detail-title" class="detail-title-text"></span>
|
|
94
|
+
<span id="detail-status" class="badge"></span>
|
|
95
|
+
</div>
|
|
96
|
+
<button id="detail-close" class="btn-icon" title="Close">
|
|
97
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
<div id="detail-body" class="detail-body"></div>
|
|
101
|
+
</div>
|
|
95
102
|
<div class="card chat-card">
|
|
96
103
|
<div class="card-header">
|
|
97
104
|
<h2 id="chat-title">Chat</h2>
|
|
@@ -313,10 +320,7 @@ body {
|
|
|
313
320
|
flex: 1;
|
|
314
321
|
display: flex;
|
|
315
322
|
flex-direction: column;
|
|
316
|
-
padding: 16px;
|
|
317
|
-
gap: 16px;
|
|
318
323
|
min-width: 0;
|
|
319
|
-
overflow-y: auto;
|
|
320
324
|
}
|
|
321
325
|
|
|
322
326
|
.panel-right {
|
|
@@ -368,23 +372,83 @@ body {
|
|
|
368
372
|
flex: 1;
|
|
369
373
|
display: flex;
|
|
370
374
|
flex-direction: column;
|
|
371
|
-
|
|
375
|
+
border-radius: 0;
|
|
376
|
+
border: none;
|
|
372
377
|
}
|
|
373
378
|
|
|
374
|
-
#mesh-
|
|
379
|
+
#mesh-container {
|
|
375
380
|
flex: 1;
|
|
376
381
|
width: 100%;
|
|
377
|
-
|
|
382
|
+
overflow: hidden;
|
|
383
|
+
}
|
|
384
|
+
#mesh-container canvas {
|
|
385
|
+
width: 100% !important;
|
|
386
|
+
height: 100% !important;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* Node detail (right panel) */
|
|
390
|
+
.detail-panel {
|
|
391
|
+
flex: 1 1 50%;
|
|
392
|
+
min-height: 0;
|
|
393
|
+
overflow-y: auto;
|
|
394
|
+
border-bottom: 1px solid var(--border);
|
|
395
|
+
background: var(--bg-card);
|
|
396
|
+
animation: slideDown 0.2s ease-out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
@keyframes slideDown {
|
|
400
|
+
from { opacity: 0; }
|
|
401
|
+
to { opacity: 1; }
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.detail-header {
|
|
405
|
+
display: flex;
|
|
406
|
+
align-items: center;
|
|
407
|
+
justify-content: space-between;
|
|
408
|
+
padding: 10px 16px;
|
|
409
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.detail-header-left {
|
|
413
|
+
display: flex;
|
|
414
|
+
align-items: center;
|
|
415
|
+
gap: 8px;
|
|
416
|
+
min-width: 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.detail-dot {
|
|
420
|
+
width: 8px;
|
|
421
|
+
height: 8px;
|
|
422
|
+
border-radius: 50%;
|
|
423
|
+
flex-shrink: 0;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.detail-title-text {
|
|
427
|
+
font-size: 13px;
|
|
428
|
+
font-weight: 600;
|
|
429
|
+
color: var(--text);
|
|
430
|
+
white-space: nowrap;
|
|
431
|
+
overflow: hidden;
|
|
432
|
+
text-overflow: ellipsis;
|
|
378
433
|
}
|
|
379
434
|
|
|
380
|
-
|
|
381
|
-
|
|
435
|
+
.btn-icon {
|
|
436
|
+
background: transparent;
|
|
437
|
+
border: none;
|
|
438
|
+
color: var(--text-dim);
|
|
439
|
+
cursor: pointer;
|
|
440
|
+
padding: 4px;
|
|
441
|
+
border-radius: 4px;
|
|
442
|
+
display: flex;
|
|
443
|
+
align-items: center;
|
|
444
|
+
transition: color 0.15s, background 0.15s;
|
|
445
|
+
}
|
|
446
|
+
.btn-icon:hover { color: var(--text); background: rgba(255,255,255,0.05); }
|
|
382
447
|
|
|
383
448
|
.detail-body {
|
|
384
|
-
padding: 16px
|
|
449
|
+
padding: 12px 16px;
|
|
385
450
|
font-size: 13px;
|
|
386
451
|
line-height: 1.7;
|
|
387
|
-
max-height: 320px;
|
|
388
452
|
overflow-y: auto;
|
|
389
453
|
}
|
|
390
454
|
|
|
@@ -473,7 +537,8 @@ body {
|
|
|
473
537
|
.chat-card {
|
|
474
538
|
display: flex;
|
|
475
539
|
flex-direction: column;
|
|
476
|
-
|
|
540
|
+
flex: 1 1 50%;
|
|
541
|
+
min-height: 0;
|
|
477
542
|
border-radius: 0;
|
|
478
543
|
border: none;
|
|
479
544
|
border-top: none;
|
|
@@ -627,15 +692,11 @@ const JS = `
|
|
|
627
692
|
let selectedNode = null;
|
|
628
693
|
let chatMessages = [];
|
|
629
694
|
let chatStreaming = false;
|
|
630
|
-
let meshNodes = [];
|
|
631
|
-
let meshEdges = [];
|
|
632
695
|
let chatMode = 'model'; // 'model' | 'handoff'
|
|
633
696
|
let handoffNodeId = null;
|
|
634
697
|
let hoveredNode = null;
|
|
635
|
-
let dragNode = null;
|
|
636
|
-
let dragOffset = { x: 0, y: 0 };
|
|
637
|
-
let animFrame = null;
|
|
638
698
|
let pollTimer = null;
|
|
699
|
+
let graph = null;
|
|
639
700
|
|
|
640
701
|
// ── DOM refs ──
|
|
641
702
|
const $ = (id) => document.getElementById(id);
|
|
@@ -644,8 +705,6 @@ const JS = `
|
|
|
644
705
|
const loginForm = $('login-form');
|
|
645
706
|
const loginToken = $('login-token');
|
|
646
707
|
const loginError = $('login-error');
|
|
647
|
-
const canvas = $('mesh-canvas');
|
|
648
|
-
const ctx = canvas.getContext('2d');
|
|
649
708
|
|
|
650
709
|
// ── Auth ──
|
|
651
710
|
loginForm.addEventListener('submit', async (e) => {
|
|
@@ -698,13 +757,10 @@ const JS = `
|
|
|
698
757
|
initMesh();
|
|
699
758
|
pollStatus();
|
|
700
759
|
pollTimer = setInterval(pollStatus, 3000);
|
|
701
|
-
requestAnimationFrame(renderLoop);
|
|
702
760
|
}
|
|
703
761
|
|
|
704
762
|
function stopDashboard() {
|
|
705
763
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
706
|
-
if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
|
|
707
|
-
window.removeEventListener('resize', resizeCanvas);
|
|
708
764
|
}
|
|
709
765
|
|
|
710
766
|
async function pollStatus() {
|
|
@@ -745,360 +801,372 @@ const JS = `
|
|
|
745
801
|
return h + 'h ' + m + 'm';
|
|
746
802
|
}
|
|
747
803
|
|
|
748
|
-
// ── Mesh visualization ──
|
|
749
|
-
const
|
|
750
|
-
|
|
804
|
+
// ── Mesh visualization (force-graph) ──
|
|
805
|
+
const NODE_COLORS = {
|
|
806
|
+
self: '#818cf8',
|
|
807
|
+
direct: '#34d399',
|
|
808
|
+
relay: '#fbbf24',
|
|
809
|
+
satellite: '#f472b6',
|
|
810
|
+
offline: '#555d75',
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
const GLOW_COLORS = {
|
|
814
|
+
self: [129, 140, 248],
|
|
815
|
+
direct: [52, 211, 153],
|
|
816
|
+
relay: [251, 191, 36],
|
|
817
|
+
satellite: [244, 114, 182],
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
let frameCount = 0;
|
|
751
821
|
|
|
752
822
|
function initMesh() {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
823
|
+
const container = $('mesh-container');
|
|
824
|
+
|
|
825
|
+
// Close button for detail panel
|
|
826
|
+
$('detail-close').addEventListener('click', () => {
|
|
827
|
+
selectedNode = null;
|
|
828
|
+
$('node-detail').classList.add('hidden');
|
|
829
|
+
setChatMode('model', null);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
graph = new ForceGraph()(container)
|
|
833
|
+
.backgroundColor('transparent')
|
|
834
|
+
.nodeId('id')
|
|
835
|
+
.linkSource('source')
|
|
836
|
+
.linkTarget('target')
|
|
837
|
+
.nodeVal(node => node.type === 'self' ? 8 : node.type === 'satellite' ? 3 : 5)
|
|
838
|
+
.nodeCanvasObjectMode(() => 'replace')
|
|
839
|
+
.nodeCanvasObject((node, ctx, globalScale) => {
|
|
840
|
+
const isSelected = selectedNode === node.id;
|
|
841
|
+
const isHovered = hoveredNode === node.id;
|
|
842
|
+
const r = node.type === 'self' ? 16 : node.type === 'satellite' ? 9 : 12;
|
|
843
|
+
const color = NODE_COLORS[node.type] || NODE_COLORS.offline;
|
|
844
|
+
const glowRgb = GLOW_COLORS[node.type];
|
|
845
|
+
|
|
846
|
+
// Animated pulse glow
|
|
847
|
+
if (glowRgb) {
|
|
848
|
+
const pulse = 0.5 + 0.5 * Math.sin(frameCount * 0.03 + (node.__idx || 0) * 1.5);
|
|
849
|
+
const glowR = r * (2.5 + (isHovered || isSelected ? 1.0 : 0) + pulse * 0.5);
|
|
850
|
+
const glowAlpha = isSelected ? 0.25 : isHovered ? 0.2 : 0.08 + pulse * 0.04;
|
|
851
|
+
const grad = ctx.createRadialGradient(node.x, node.y, r * 0.5, node.x, node.y, glowR);
|
|
852
|
+
grad.addColorStop(0, 'rgba(' + glowRgb.join(',') + ',' + glowAlpha + ')');
|
|
853
|
+
grad.addColorStop(0.6, 'rgba(' + glowRgb.join(',') + ',' + (glowAlpha * 0.3) + ')');
|
|
854
|
+
grad.addColorStop(1, 'transparent');
|
|
855
|
+
ctx.fillStyle = grad;
|
|
856
|
+
ctx.fillRect(node.x - glowR, node.y - glowR, glowR * 2, glowR * 2);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Outer ring for selected/hovered
|
|
860
|
+
if (isSelected || isHovered) {
|
|
861
|
+
ctx.beginPath();
|
|
862
|
+
ctx.arc(node.x, node.y, r + 3, 0, Math.PI * 2);
|
|
863
|
+
ctx.strokeStyle = color + (isSelected ? '60' : '30');
|
|
864
|
+
ctx.lineWidth = 2;
|
|
865
|
+
ctx.stroke();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Main circle with gradient fill
|
|
869
|
+
const fillGrad = ctx.createRadialGradient(node.x - r * 0.3, node.y - r * 0.3, 0, node.x, node.y, r);
|
|
870
|
+
fillGrad.addColorStop(0, color);
|
|
871
|
+
fillGrad.addColorStop(1, color + 'aa');
|
|
872
|
+
ctx.beginPath();
|
|
873
|
+
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
|
874
|
+
ctx.fillStyle = fillGrad;
|
|
875
|
+
ctx.fill();
|
|
876
|
+
|
|
877
|
+
// Border
|
|
878
|
+
ctx.strokeStyle = isSelected ? '#fff' : isHovered ? color : color + '50';
|
|
879
|
+
ctx.lineWidth = isSelected ? 2 : isHovered ? 1.5 : 1;
|
|
880
|
+
ctx.stroke();
|
|
881
|
+
|
|
882
|
+
// Inner ring for self with animated rotation
|
|
883
|
+
if (node.type === 'self') {
|
|
884
|
+
ctx.save();
|
|
885
|
+
ctx.translate(node.x, node.y);
|
|
886
|
+
ctx.rotate(frameCount * 0.008);
|
|
887
|
+
ctx.beginPath();
|
|
888
|
+
ctx.arc(0, 0, r - 5, 0, Math.PI * 1.5);
|
|
889
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
|
890
|
+
ctx.lineWidth = 1.5;
|
|
891
|
+
ctx.stroke();
|
|
892
|
+
ctx.restore();
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Label with shadow
|
|
896
|
+
ctx.textAlign = 'center';
|
|
897
|
+
ctx.textBaseline = 'middle';
|
|
898
|
+
const fontSize = Math.max((node.type === 'self' ? 12 : 11) / globalScale, 3);
|
|
899
|
+
ctx.font = (node.type === 'self' ? '600 ' : '500 ') + fontSize + 'px -apple-system, system-ui, sans-serif';
|
|
900
|
+
const labelY = node.y + r + 12 / globalScale;
|
|
901
|
+
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
|
902
|
+
ctx.fillText(node.id, node.x + 0.5 / globalScale, labelY + 0.5 / globalScale);
|
|
903
|
+
ctx.fillStyle = isSelected || isHovered ? '#fff' : 'rgba(255,255,255,0.8)';
|
|
904
|
+
ctx.fillText(node.id, node.x, labelY);
|
|
905
|
+
|
|
906
|
+
// Capability counts
|
|
907
|
+
const data = node.data;
|
|
908
|
+
if (data) {
|
|
909
|
+
const parts = [];
|
|
910
|
+
if (node.type === 'satellite') {
|
|
911
|
+
parts.push(data.ssid ? data.ssid : 'cellular');
|
|
912
|
+
} else {
|
|
913
|
+
const agents = data.agents?.length || 0;
|
|
914
|
+
const models = data.models?.length || 0;
|
|
915
|
+
const tools = data.toolProxy?.enabled ? (data.toolProxy.allow?.length || 0) : 0;
|
|
916
|
+
if (models) parts.push(models + 'M');
|
|
917
|
+
if (agents) parts.push(agents + 'A');
|
|
918
|
+
if (tools) parts.push(tools + 'T');
|
|
919
|
+
}
|
|
920
|
+
if (parts.length) {
|
|
921
|
+
ctx.font = Math.max(10 / globalScale, 2.5) + 'px -apple-system, system-ui, sans-serif';
|
|
922
|
+
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
|
923
|
+
ctx.fillText(parts.join(' \\u00b7 '), node.x, labelY + 13 / globalScale);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
})
|
|
927
|
+
.linkCanvasObjectMode(() => 'replace')
|
|
928
|
+
.linkCanvasObject((link, ctx, globalScale) => {
|
|
929
|
+
const src = link.source;
|
|
930
|
+
const tgt = link.target;
|
|
931
|
+
if (!src || !tgt || src.x == null || tgt.x == null) return;
|
|
932
|
+
|
|
933
|
+
const isDirect = link.type === 'direct';
|
|
934
|
+
const isSatellite = link.type === 'satellite';
|
|
935
|
+
const rgb = getLinkColorRgb(src, tgt);
|
|
936
|
+
|
|
937
|
+
// Link line with glow
|
|
938
|
+
ctx.beginPath();
|
|
939
|
+
ctx.moveTo(src.x, src.y);
|
|
940
|
+
ctx.lineTo(tgt.x, tgt.y);
|
|
941
|
+
|
|
942
|
+
// Glow layer
|
|
943
|
+
ctx.strokeStyle = 'rgba(' + rgb + ',0.12)';
|
|
944
|
+
ctx.lineWidth = isDirect ? 6 : 4;
|
|
945
|
+
ctx.setLineDash([]);
|
|
946
|
+
ctx.stroke();
|
|
947
|
+
|
|
948
|
+
// Main line
|
|
949
|
+
ctx.beginPath();
|
|
950
|
+
ctx.moveTo(src.x, src.y);
|
|
951
|
+
ctx.lineTo(tgt.x, tgt.y);
|
|
952
|
+
ctx.strokeStyle = 'rgba(' + rgb + ',' + (isDirect ? '0.55' : '0.4') + ')';
|
|
953
|
+
ctx.lineWidth = isDirect ? 2 : 1.5;
|
|
954
|
+
if (isSatellite) ctx.setLineDash([3, 5]);
|
|
955
|
+
else if (!isDirect) ctx.setLineDash([6, 4]);
|
|
956
|
+
else ctx.setLineDash([]);
|
|
957
|
+
ctx.stroke();
|
|
958
|
+
ctx.setLineDash([]);
|
|
959
|
+
})
|
|
960
|
+
.linkDirectionalParticles(link => link.type === 'direct' ? 4 : 2)
|
|
961
|
+
.linkDirectionalParticleSpeed(link => link.type === 'direct' ? 0.004 : 0.003)
|
|
962
|
+
.linkDirectionalParticleWidth(link => link.type === 'direct' ? 3 : 2)
|
|
963
|
+
.linkDirectionalParticleColor(link => {
|
|
964
|
+
return 'rgba(' + getLinkColorRgb(link.source, link.target) + ',0.8)';
|
|
965
|
+
})
|
|
966
|
+
.onNodeHover(node => {
|
|
967
|
+
hoveredNode = node ? node.id : null;
|
|
968
|
+
container.style.cursor = node ? 'pointer' : 'default';
|
|
969
|
+
})
|
|
970
|
+
.onNodeClick((node) => {
|
|
971
|
+
selectedNode = node.id;
|
|
972
|
+
updateDetail(node.id);
|
|
973
|
+
setChatMode(node.id !== state.nodeId ? 'handoff' : 'model', node.id !== state.nodeId ? node.id : null);
|
|
974
|
+
})
|
|
975
|
+
.onBackgroundClick(() => {
|
|
976
|
+
selectedNode = null;
|
|
977
|
+
$('node-detail').classList.add('hidden');
|
|
978
|
+
setChatMode('model', null);
|
|
979
|
+
})
|
|
980
|
+
.onNodeDragEnd(node => {
|
|
981
|
+
node.fx = node.x;
|
|
982
|
+
node.fy = node.y;
|
|
983
|
+
})
|
|
984
|
+
.onRenderFramePre((ctx, globalScale) => {
|
|
985
|
+
frameCount++;
|
|
986
|
+
// Subtle radial gradient background
|
|
987
|
+
const w = graph.width();
|
|
988
|
+
const h = graph.height();
|
|
989
|
+
const bgGrad = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.max(w, h) * 0.6);
|
|
990
|
+
bgGrad.addColorStop(0, 'rgba(99, 102, 241, 0.03)');
|
|
991
|
+
bgGrad.addColorStop(1, 'transparent');
|
|
992
|
+
ctx.fillStyle = bgGrad;
|
|
993
|
+
ctx.fillRect(-w / 2, -h / 2, w, h);
|
|
994
|
+
|
|
995
|
+
// Grid dots instead of lines
|
|
996
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)';
|
|
997
|
+
const gridSize = 30 / globalScale;
|
|
998
|
+
const dotR = 1 / globalScale;
|
|
999
|
+
const xMin = -w / 2;
|
|
1000
|
+
const yMin = -h / 2;
|
|
1001
|
+
const xMax = w / 2;
|
|
1002
|
+
const yMax = h / 2;
|
|
1003
|
+
for (let x = Math.floor(xMin / gridSize) * gridSize; x < xMax; x += gridSize) {
|
|
1004
|
+
for (let y = Math.floor(yMin / gridSize) * gridSize; y < yMax; y += gridSize) {
|
|
1005
|
+
ctx.beginPath();
|
|
1006
|
+
ctx.arc(x, y, dotR, 0, Math.PI * 2);
|
|
1007
|
+
ctx.fill();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
})
|
|
1011
|
+
.cooldownTime(Infinity)
|
|
1012
|
+
.d3AlphaMin(0);
|
|
1013
|
+
|
|
1014
|
+
// Configure forces — strong repulsion to spread nodes apart
|
|
1015
|
+
graph.d3Force('link').distance(200);
|
|
1016
|
+
graph.d3Force('charge').strength(-800).distanceMin(50);
|
|
1017
|
+
graph.d3Force('center', null);
|
|
1018
|
+
|
|
1019
|
+
// Observe container resize
|
|
1020
|
+
new ResizeObserver(() => {
|
|
1021
|
+
const rect = container.getBoundingClientRect();
|
|
1022
|
+
const headerH = container.parentElement.querySelector('.card-header')?.offsetHeight || 0;
|
|
1023
|
+
graph.width(rect.width).height(rect.height - headerH);
|
|
1024
|
+
}).observe(container.parentElement);
|
|
1025
|
+
|
|
1026
|
+
// Trigger initial size
|
|
1027
|
+
const rect = container.getBoundingClientRect();
|
|
1028
|
+
const headerH = container.parentElement.querySelector('.card-header')?.offsetHeight || 0;
|
|
1029
|
+
graph.width(rect.width).height(rect.height - headerH);
|
|
759
1030
|
}
|
|
760
1031
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1032
|
+
// Color palette for distinguishing different WSS connections
|
|
1033
|
+
const LINK_COLORS = [
|
|
1034
|
+
[129, 140, 248], // indigo
|
|
1035
|
+
[52, 211, 153], // emerald
|
|
1036
|
+
[251, 146, 60], // orange
|
|
1037
|
+
[167, 139, 250], // violet
|
|
1038
|
+
[56, 189, 248], // sky
|
|
1039
|
+
[251, 191, 36], // amber
|
|
1040
|
+
[244, 114, 182], // pink
|
|
1041
|
+
[45, 212, 191], // teal
|
|
1042
|
+
[248, 113, 113], // red
|
|
1043
|
+
[163, 230, 53], // lime
|
|
1044
|
+
];
|
|
1045
|
+
let linkColorIndex = 0;
|
|
1046
|
+
const linkColorMap = {}; // edge key → color index
|
|
1047
|
+
|
|
1048
|
+
function getLinkColorRgb(source, target) {
|
|
1049
|
+
const s = typeof source === 'object' ? source.id : source;
|
|
1050
|
+
const t = typeof target === 'object' ? target.id : target;
|
|
1051
|
+
const key = [s, t].sort().join('::');
|
|
1052
|
+
if (!(key in linkColorMap)) {
|
|
1053
|
+
linkColorMap[key] = linkColorIndex % LINK_COLORS.length;
|
|
1054
|
+
linkColorIndex++;
|
|
1055
|
+
}
|
|
1056
|
+
const c = LINK_COLORS[linkColorMap[key]];
|
|
1057
|
+
return c[0] + ',' + c[1] + ',' + c[2];
|
|
772
1058
|
}
|
|
773
1059
|
|
|
774
|
-
function
|
|
775
|
-
const
|
|
1060
|
+
function buildGraphInputs() {
|
|
1061
|
+
const allPeers = state.peers.map((p, i) => ({
|
|
1062
|
+
id: p.nodeId,
|
|
1063
|
+
type: p.connection === 'satellite' ? 'satellite' : (p.online ? (p.connection === 'direct' ? 'direct' : 'relay') : 'offline'),
|
|
1064
|
+
data: p,
|
|
1065
|
+
}));
|
|
1066
|
+
const nodes = [
|
|
776
1067
|
{ id: state.nodeId, type: 'self', data: state.local },
|
|
777
|
-
...
|
|
778
|
-
id: p.nodeId,
|
|
779
|
-
type: p.online ? (p.connection === 'direct' ? 'direct' : 'relay') : 'offline',
|
|
780
|
-
data: p,
|
|
781
|
-
})),
|
|
1068
|
+
...allPeers,
|
|
782
1069
|
];
|
|
1070
|
+
// Spread initial positions in a circle so nodes don't start at (0,0)
|
|
1071
|
+
const radius = 120;
|
|
1072
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1073
|
+
const n = nodes[i];
|
|
1074
|
+
if (n.x == null) {
|
|
1075
|
+
const angle = (2 * Math.PI * i) / nodes.length - Math.PI / 2;
|
|
1076
|
+
n.x = radius * Math.cos(angle);
|
|
1077
|
+
n.y = radius * Math.sin(angle);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
783
1080
|
|
|
784
|
-
|
|
785
|
-
const
|
|
786
|
-
for (const n of meshNodes) oldPositions[n.id] = { x: n.x, y: n.y, vx: n.vx, vy: n.vy };
|
|
787
|
-
|
|
788
|
-
meshNodes = allNodes.map((n, i) => {
|
|
789
|
-
const old = oldPositions[n.id];
|
|
790
|
-
if (old) return { ...n, x: old.x, y: old.y, vx: old.vx, vy: old.vy };
|
|
791
|
-
// New node: random position
|
|
792
|
-
const angle = (i / allNodes.length) * Math.PI * 2 - Math.PI / 2;
|
|
793
|
-
const r = n.type === 'self' ? 0 : 120 + Math.random() * 40;
|
|
794
|
-
return {
|
|
795
|
-
...n,
|
|
796
|
-
x: W / 2 + Math.cos(angle) * r,
|
|
797
|
-
y: H / 2 + Math.sin(angle) * r,
|
|
798
|
-
vx: 0,
|
|
799
|
-
vy: 0,
|
|
800
|
-
};
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
// Build edges
|
|
804
|
-
meshEdges = [];
|
|
805
|
-
const edgeSet = new Set();
|
|
1081
|
+
const links = [];
|
|
1082
|
+
const edgeMap = {}; // pairKey → link object (dedup, direct wins over relay)
|
|
806
1083
|
function addEdge(a, b, type) {
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1084
|
+
const pairKey = [a, b].sort().join('::');
|
|
1085
|
+
const existing = edgeMap[pairKey];
|
|
1086
|
+
// direct > relay > satellite: keep the stronger type
|
|
1087
|
+
if (existing) {
|
|
1088
|
+
if (existing.type === 'direct') return; // already best
|
|
1089
|
+
if (type === 'direct') { existing.type = 'direct'; return; }
|
|
1090
|
+
return; // keep first
|
|
1091
|
+
}
|
|
1092
|
+
getLinkColorRgb(a, b); // pre-register color for this pair
|
|
1093
|
+
const link = { source: a, target: b, type: type };
|
|
1094
|
+
edgeMap[pairKey] = link;
|
|
1095
|
+
links.push(link);
|
|
811
1096
|
}
|
|
812
1097
|
|
|
813
|
-
// Edges from self to direct peers
|
|
814
1098
|
for (const p of state.peers) {
|
|
815
|
-
if (p.connection === '
|
|
1099
|
+
if (p.connection === 'satellite') {
|
|
1100
|
+
addEdge(state.nodeId, p.nodeId, 'satellite');
|
|
1101
|
+
} else if (p.connection === 'direct') {
|
|
816
1102
|
addEdge(state.nodeId, p.nodeId, 'direct');
|
|
817
1103
|
} else if (p.reachableVia) {
|
|
818
1104
|
addEdge(p.reachableVia, p.nodeId, 'relay');
|
|
819
1105
|
addEdge(state.nodeId, p.reachableVia, 'direct');
|
|
820
1106
|
}
|
|
821
1107
|
}
|
|
822
|
-
|
|
823
|
-
// Edges between remote peers (from directPeers data)
|
|
1108
|
+
const nodeIds = new Set(nodes.map(n => n.id));
|
|
824
1109
|
for (const p of state.peers) {
|
|
825
1110
|
if (p.directPeers) {
|
|
826
1111
|
for (const dp of p.directPeers) {
|
|
827
|
-
if (dp !== state.nodeId) {
|
|
1112
|
+
if (dp !== state.nodeId && nodeIds.has(dp)) {
|
|
828
1113
|
addEdge(p.nodeId, dp, 'direct');
|
|
829
1114
|
}
|
|
830
1115
|
}
|
|
831
1116
|
}
|
|
832
1117
|
}
|
|
833
1118
|
|
|
834
|
-
|
|
1119
|
+
return { nodes, links };
|
|
835
1120
|
}
|
|
836
1121
|
|
|
837
|
-
function
|
|
838
|
-
|
|
839
|
-
const
|
|
840
|
-
|
|
1122
|
+
function linkKey(l) {
|
|
1123
|
+
const s = typeof l.source === 'object' ? l.source.id : l.source;
|
|
1124
|
+
const t = typeof l.target === 'object' ? l.target.id : l.target;
|
|
1125
|
+
return [s, t].sort().join('::');
|
|
841
1126
|
}
|
|
842
1127
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
const
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
fy += dy * f;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// Spring to connected nodes
|
|
869
|
-
for (const edge of meshEdges) {
|
|
870
|
-
let other = null;
|
|
871
|
-
if (edge.from === a.id) other = meshNodes.find(n => n.id === edge.to);
|
|
872
|
-
else if (edge.to === a.id) other = meshNodes.find(n => n.id === edge.from);
|
|
873
|
-
if (!other) continue;
|
|
874
|
-
|
|
875
|
-
const dx = other.x - a.x;
|
|
876
|
-
const dy = other.y - a.y;
|
|
877
|
-
const d = Math.sqrt(dx * dx + dy * dy);
|
|
878
|
-
const desired = 150;
|
|
879
|
-
const f = k * (d - desired);
|
|
880
|
-
fx += dx / d * f;
|
|
881
|
-
fy += dy / d * f;
|
|
1128
|
+
function updateMeshData() {
|
|
1129
|
+
if (!graph) return;
|
|
1130
|
+
const { nodes: newNodes, links: newLinks } = buildGraphInputs();
|
|
1131
|
+
const { nodes: curNodes, links: curLinks } = graph.graphData();
|
|
1132
|
+
|
|
1133
|
+
// Check if topology actually changed
|
|
1134
|
+
const curNodeIds = new Set(curNodes.map(n => n.id));
|
|
1135
|
+
const newNodeIds = new Set(newNodes.map(n => n.id));
|
|
1136
|
+
const curLinkKeys = new Set(curLinks.map(linkKey));
|
|
1137
|
+
const newLinkKeys = new Set(newLinks.map(linkKey));
|
|
1138
|
+
|
|
1139
|
+
const nodesChanged = newNodeIds.size !== curNodeIds.size || [...newNodeIds].some(id => !curNodeIds.has(id));
|
|
1140
|
+
const linksChanged = newLinkKeys.size !== curLinkKeys.size || [...newLinkKeys].some(k => !curLinkKeys.has(k));
|
|
1141
|
+
|
|
1142
|
+
// Always update node data (type, capabilities) in place
|
|
1143
|
+
const curMap = {};
|
|
1144
|
+
for (const n of curNodes) curMap[n.id] = n;
|
|
1145
|
+
for (const nn of newNodes) {
|
|
1146
|
+
const cur = curMap[nn.id];
|
|
1147
|
+
if (cur) {
|
|
1148
|
+
cur.type = nn.type;
|
|
1149
|
+
cur.data = nn.data;
|
|
882
1150
|
}
|
|
883
|
-
|
|
884
|
-
// Gravity toward center
|
|
885
|
-
fx += (center.x - a.x) * 0.001;
|
|
886
|
-
fy += (center.y - a.y) * 0.001;
|
|
887
|
-
|
|
888
|
-
a.vx = (a.vx + fx) * damping;
|
|
889
|
-
a.vy = (a.vy + fy) * damping;
|
|
890
|
-
a.x += a.vx;
|
|
891
|
-
a.y += a.vy;
|
|
892
|
-
|
|
893
|
-
// Bounds
|
|
894
|
-
const pad = 60;
|
|
895
|
-
a.x = Math.max(pad, Math.min(W - pad, a.x));
|
|
896
|
-
a.y = Math.max(pad, Math.min(H - pad, a.y));
|
|
897
1151
|
}
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
let particleTime = 0;
|
|
901
|
-
|
|
902
|
-
function renderMesh() {
|
|
903
|
-
ctx.clearRect(0, 0, W, H);
|
|
904
|
-
|
|
905
|
-
// Grid background
|
|
906
|
-
ctx.strokeStyle = 'rgba(30, 32, 48, 0.5)';
|
|
907
|
-
ctx.lineWidth = 0.5;
|
|
908
|
-
const gridSize = 40;
|
|
909
|
-
for (let x = 0; x < W; x += gridSize) {
|
|
910
|
-
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
|
911
|
-
}
|
|
912
|
-
for (let y = 0; y < H; y += gridSize) {
|
|
913
|
-
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
particleTime += 0.015;
|
|
917
|
-
|
|
918
|
-
// Draw edges
|
|
919
|
-
for (const edge of meshEdges) {
|
|
920
|
-
const a = meshNodes.find(n => n.id === edge.from);
|
|
921
|
-
const b = meshNodes.find(n => n.id === edge.to);
|
|
922
|
-
if (!a || !b) continue;
|
|
923
1152
|
|
|
924
|
-
|
|
925
|
-
ctx.beginPath();
|
|
926
|
-
ctx.moveTo(a.x, a.y);
|
|
927
|
-
ctx.lineTo(b.x, b.y);
|
|
1153
|
+
if (!nodesChanged && !linksChanged) return; // topology unchanged, skip
|
|
928
1154
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
} else {
|
|
934
|
-
ctx.strokeStyle = 'rgba(251, 191, 36, 0.25)';
|
|
935
|
-
ctx.lineWidth = 1.5;
|
|
936
|
-
ctx.setLineDash([6, 4]);
|
|
937
|
-
}
|
|
938
|
-
ctx.stroke();
|
|
939
|
-
ctx.setLineDash([]);
|
|
940
|
-
|
|
941
|
-
// Animated particles along edge
|
|
942
|
-
const color = isDirect ? '99, 102, 241' : '251, 191, 36';
|
|
943
|
-
const dx = b.x - a.x;
|
|
944
|
-
const dy = b.y - a.y;
|
|
945
|
-
const len = Math.sqrt(dx * dx + dy * dy);
|
|
946
|
-
if (len < 10) continue;
|
|
947
|
-
|
|
948
|
-
for (let i = 0; i < 3; i++) {
|
|
949
|
-
const t = ((particleTime * 0.5 + i * 0.33) % 1);
|
|
950
|
-
const px = a.x + dx * t;
|
|
951
|
-
const py = a.y + dy * t;
|
|
952
|
-
ctx.beginPath();
|
|
953
|
-
ctx.arc(px, py, 2, 0, Math.PI * 2);
|
|
954
|
-
ctx.fillStyle = 'rgba(' + color + ', ' + (0.6 - t * 0.4) + ')';
|
|
955
|
-
ctx.fill();
|
|
956
|
-
}
|
|
1155
|
+
// Topology changed — preserve positions of existing nodes
|
|
1156
|
+
const posMap = {};
|
|
1157
|
+
for (const n of curNodes) {
|
|
1158
|
+
posMap[n.id] = { x: n.x, y: n.y, vx: n.vx, vy: n.vy, fx: n.fx, fy: n.fy };
|
|
957
1159
|
}
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
// Glow
|
|
966
|
-
if (node.type !== 'offline') {
|
|
967
|
-
const glowColor = node.type === 'self' ? 'rgba(129,140,248,0.15)' :
|
|
968
|
-
node.type === 'direct' ? 'rgba(52,211,153,0.12)' :
|
|
969
|
-
'rgba(251,191,36,0.1)';
|
|
970
|
-
const grad = ctx.createRadialGradient(node.x, node.y, r, node.x, node.y, r * 2.5);
|
|
971
|
-
grad.addColorStop(0, glowColor);
|
|
972
|
-
grad.addColorStop(1, 'transparent');
|
|
973
|
-
ctx.fillStyle = grad;
|
|
974
|
-
ctx.fillRect(node.x - r * 3, node.y - r * 3, r * 6, r * 6);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
// Node circle
|
|
978
|
-
ctx.beginPath();
|
|
979
|
-
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
|
980
|
-
|
|
981
|
-
const color = node.type === 'self' ? '#818cf8' :
|
|
982
|
-
node.type === 'direct' ? '#34d399' :
|
|
983
|
-
node.type === 'relay' ? '#fbbf24' : '#555d75';
|
|
984
|
-
|
|
985
|
-
ctx.fillStyle = isHovered || isSelected ? color : adjustAlpha(color, 0.8);
|
|
986
|
-
ctx.fill();
|
|
987
|
-
|
|
988
|
-
// Border
|
|
989
|
-
ctx.strokeStyle = isSelected ? '#fff' : isHovered ? adjustAlpha(color, 1) : adjustAlpha(color, 0.3);
|
|
990
|
-
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5;
|
|
991
|
-
ctx.stroke();
|
|
992
|
-
|
|
993
|
-
// Inner ring for self
|
|
994
|
-
if (node.type === 'self') {
|
|
995
|
-
ctx.beginPath();
|
|
996
|
-
ctx.arc(node.x, node.y, r - 6, 0, Math.PI * 2);
|
|
997
|
-
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
|
998
|
-
ctx.lineWidth = 1;
|
|
999
|
-
ctx.stroke();
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
// Label
|
|
1003
|
-
ctx.textAlign = 'center';
|
|
1004
|
-
ctx.textBaseline = 'middle';
|
|
1005
|
-
ctx.font = (node.type === 'self' ? '600 12px' : '500 11px') + ' -apple-system, system-ui, sans-serif';
|
|
1006
|
-
ctx.fillStyle = '#fff';
|
|
1007
|
-
ctx.fillText(node.id, node.x, node.y + r + 16);
|
|
1008
|
-
|
|
1009
|
-
// Capability counts below label
|
|
1010
|
-
const data = node.data;
|
|
1011
|
-
if (data) {
|
|
1012
|
-
const parts = [];
|
|
1013
|
-
const agents = data.agents?.length || 0;
|
|
1014
|
-
const models = data.models?.length || 0;
|
|
1015
|
-
if (models) parts.push(models + 'M');
|
|
1016
|
-
if (agents) parts.push(agents + 'A');
|
|
1017
|
-
if (parts.length) {
|
|
1018
|
-
ctx.font = '10px -apple-system, system-ui, sans-serif';
|
|
1019
|
-
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
|
1020
|
-
ctx.fillText(parts.join(' · '), node.x, node.y + r + 30);
|
|
1021
|
-
}
|
|
1160
|
+
for (const n of newNodes) {
|
|
1161
|
+
const old = posMap[n.id];
|
|
1162
|
+
if (old) {
|
|
1163
|
+
n.x = old.x; n.y = old.y;
|
|
1164
|
+
n.vx = old.vx; n.vy = old.vy;
|
|
1165
|
+
n.fx = old.fx; n.fy = old.fy;
|
|
1022
1166
|
}
|
|
1023
1167
|
}
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
function adjustAlpha(hex, alpha) {
|
|
1027
|
-
const r = parseInt(hex.slice(1, 3), 16);
|
|
1028
|
-
const g = parseInt(hex.slice(3, 5), 16);
|
|
1029
|
-
const b = parseInt(hex.slice(5, 7), 16);
|
|
1030
|
-
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
|
|
1031
|
-
}
|
|
1032
1168
|
|
|
1033
|
-
|
|
1034
|
-
simulateForces();
|
|
1035
|
-
renderMesh();
|
|
1036
|
-
animFrame = requestAnimationFrame(renderLoop);
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
function hitTest(mx, my) {
|
|
1040
|
-
for (let i = meshNodes.length - 1; i >= 0; i--) {
|
|
1041
|
-
const n = meshNodes[i];
|
|
1042
|
-
const r = n.type === 'self' ? 28 : 22;
|
|
1043
|
-
const dx = mx - n.x;
|
|
1044
|
-
const dy = my - n.y;
|
|
1045
|
-
if (dx * dx + dy * dy <= (r + 8) * (r + 8)) return n;
|
|
1046
|
-
}
|
|
1047
|
-
return null;
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
function getCanvasPos(e) {
|
|
1051
|
-
const rect = canvas.getBoundingClientRect();
|
|
1052
|
-
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
function onCanvasMouseDown(e) {
|
|
1056
|
-
const { x, y } = getCanvasPos(e);
|
|
1057
|
-
const hit = hitTest(x, y);
|
|
1058
|
-
if (hit) {
|
|
1059
|
-
dragNode = hit;
|
|
1060
|
-
dragOffset.x = x - hit.x;
|
|
1061
|
-
dragOffset.y = y - hit.y;
|
|
1062
|
-
hit._pinned = true;
|
|
1063
|
-
canvas.style.cursor = 'grabbing';
|
|
1064
|
-
// Also select on click
|
|
1065
|
-
selectedNode = hit.id;
|
|
1066
|
-
updateDetail(hit.id);
|
|
1067
|
-
setChatMode(hit.id !== state.nodeId ? 'handoff' : 'model', hit.id !== state.nodeId ? hit.id : null);
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
function onCanvasMouseMove(e) {
|
|
1072
|
-
const { x, y } = getCanvasPos(e);
|
|
1073
|
-
if (dragNode) {
|
|
1074
|
-
dragNode.x = x - dragOffset.x;
|
|
1075
|
-
dragNode.y = y - dragOffset.y;
|
|
1076
|
-
canvas.style.cursor = 'grabbing';
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
const hit = hitTest(x, y);
|
|
1080
|
-
hoveredNode = hit ? hit.id : null;
|
|
1081
|
-
canvas.style.cursor = hit ? 'grab' : 'default';
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
function onCanvasMouseUp(e) {
|
|
1085
|
-
if (dragNode) {
|
|
1086
|
-
// Keep self pinned to where it was dropped; release others after a delay
|
|
1087
|
-
if (dragNode.type !== 'self') {
|
|
1088
|
-
const node = dragNode;
|
|
1089
|
-
setTimeout(() => { node._pinned = false; }, 2000);
|
|
1090
|
-
}
|
|
1091
|
-
dragNode = null;
|
|
1092
|
-
canvas.style.cursor = 'default';
|
|
1093
|
-
} else {
|
|
1094
|
-
const { x, y } = getCanvasPos(e);
|
|
1095
|
-
const hit = hitTest(x, y);
|
|
1096
|
-
if (!hit) {
|
|
1097
|
-
selectedNode = null;
|
|
1098
|
-
$('node-detail').classList.add('hidden');
|
|
1099
|
-
setChatMode('model', null);
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1169
|
+
graph.graphData({ nodes: newNodes, links: newLinks });
|
|
1102
1170
|
}
|
|
1103
1171
|
|
|
1104
1172
|
// ── Node detail panel ──
|
|
@@ -1109,14 +1177,29 @@ const JS = `
|
|
|
1109
1177
|
$('node-detail').classList.add('hidden');
|
|
1110
1178
|
return;
|
|
1111
1179
|
}
|
|
1180
|
+
const isSat = nodeData.connection === 'satellite';
|
|
1112
1181
|
|
|
1113
1182
|
$('node-detail').classList.remove('hidden');
|
|
1114
1183
|
$('detail-title').textContent = nodeId;
|
|
1115
1184
|
|
|
1185
|
+
// Status dot color
|
|
1186
|
+
const dot = $('detail-status-dot');
|
|
1187
|
+
const dotColor = isLocal ? 'var(--accent)' : isSat ? '#f472b6' : nodeData.online ? 'var(--green)' : 'var(--red)';
|
|
1188
|
+
dot.style.background = dotColor;
|
|
1189
|
+
if (isLocal || isSat || nodeData.online) dot.style.boxShadow = '0 0 6px ' + dotColor;
|
|
1190
|
+
else dot.style.boxShadow = 'none';
|
|
1191
|
+
|
|
1116
1192
|
const statusBadge = $('detail-status');
|
|
1193
|
+
statusBadge.style.background = '';
|
|
1194
|
+
statusBadge.style.color = '';
|
|
1117
1195
|
if (isLocal) {
|
|
1118
1196
|
statusBadge.textContent = 'Self';
|
|
1119
1197
|
statusBadge.className = 'badge badge-self';
|
|
1198
|
+
} else if (isSat) {
|
|
1199
|
+
statusBadge.textContent = 'Satellite';
|
|
1200
|
+
statusBadge.style.background = 'rgba(244,114,182,0.15)';
|
|
1201
|
+
statusBadge.style.color = '#f472b6';
|
|
1202
|
+
statusBadge.className = 'badge';
|
|
1120
1203
|
} else if (nodeData.online) {
|
|
1121
1204
|
statusBadge.textContent = nodeData.connection === 'direct' ? 'Direct' : 'Relay';
|
|
1122
1205
|
statusBadge.className = 'badge ' + (nodeData.connection === 'direct' ? 'badge-online' : 'badge-relay');
|
|
@@ -1127,6 +1210,41 @@ const JS = `
|
|
|
1127
1210
|
|
|
1128
1211
|
let html = '';
|
|
1129
1212
|
|
|
1213
|
+
// Satellite node detail
|
|
1214
|
+
if (isSat) {
|
|
1215
|
+
html += '<div class="detail-section">';
|
|
1216
|
+
html += '<div class="detail-label">Network</div>';
|
|
1217
|
+
html += '<div class="detail-grid">';
|
|
1218
|
+
if (nodeData.cellular) {
|
|
1219
|
+
html += '<span class="detail-key">Type</span><span>Cellular</span>';
|
|
1220
|
+
} else if (nodeData.ssid) {
|
|
1221
|
+
html += '<span class="detail-key">WiFi</span><span>' + esc(nodeData.ssid) + '</span>';
|
|
1222
|
+
}
|
|
1223
|
+
if (nodeData.ip) html += '<span class="detail-key">IP</span><span>' + esc(nodeData.ip) + '</span>';
|
|
1224
|
+
if (nodeData.router) html += '<span class="detail-key">Router</span><span>' + esc(nodeData.router) + '</span>';
|
|
1225
|
+
if (nodeData.country) html += '<span class="detail-key">Country</span><span>' + esc(nodeData.country) + '</span>';
|
|
1226
|
+
if (nodeData.location) html += '<span class="detail-key">Location</span><span>' + esc(nodeData.location) + '</span>';
|
|
1227
|
+
if (nodeData.platform) html += '<span class="detail-key">Platform</span><span>' + esc(nodeData.platform) + '</span>';
|
|
1228
|
+
if (typeof nodeData.battery === 'number') {
|
|
1229
|
+
const bat = nodeData.battery + '%' + (nodeData.charging ? ' (charging)' : '');
|
|
1230
|
+
html += '<span class="detail-key">Battery</span><span>' + esc(bat) + '</span>';
|
|
1231
|
+
}
|
|
1232
|
+
if (nodeData.lastSeen) html += '<span class="detail-key">Last seen</span><span>' + new Date(nodeData.lastSeen).toLocaleTimeString() + '</span>';
|
|
1233
|
+
html += '</div></div>';
|
|
1234
|
+
const satTools = nodeData.toolProxy?.allow || [];
|
|
1235
|
+
if (satTools.length > 0) {
|
|
1236
|
+
html += '<div class="detail-section">';
|
|
1237
|
+
html += '<div class="detail-label">Tools (' + satTools.length + ')</div>';
|
|
1238
|
+
html += '<div style="display:flex;flex-wrap:wrap;gap:4px">';
|
|
1239
|
+
for (const t of satTools) {
|
|
1240
|
+
html += '<span class="badge" style="background:rgba(244,114,182,0.1);color:#f472b6;font-size:11px">' + esc(t) + '</span>';
|
|
1241
|
+
}
|
|
1242
|
+
html += '</div></div>';
|
|
1243
|
+
}
|
|
1244
|
+
$('detail-body').innerHTML = html;
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1130
1248
|
// Device info
|
|
1131
1249
|
if (nodeData.deviceInfo) {
|
|
1132
1250
|
const d = nodeData.deviceInfo;
|
|
@@ -1179,6 +1297,33 @@ const JS = `
|
|
|
1179
1297
|
html += '</div></div>';
|
|
1180
1298
|
}
|
|
1181
1299
|
|
|
1300
|
+
// Cluster Tools
|
|
1301
|
+
if (nodeData.clusterTools?.length) {
|
|
1302
|
+
html += '<div class="detail-section">';
|
|
1303
|
+
html += '<div class="detail-label collapsible" onclick="this.classList.toggle('expanded');this.nextElementSibling.classList.toggle('expanded')">Cluster Tools (' + nodeData.clusterTools.length + ')</div>';
|
|
1304
|
+
html += '<div class="detail-items">';
|
|
1305
|
+
for (const t of nodeData.clusterTools) {
|
|
1306
|
+
html += '<div class="item-row"><span class="item-icon" style="background:var(--orange, #f59e0b)"></span>';
|
|
1307
|
+
html += '<span>' + esc(t) + '</span></div>';
|
|
1308
|
+
}
|
|
1309
|
+
html += '</div></div>';
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Tool Proxy
|
|
1313
|
+
if (nodeData.toolProxy) {
|
|
1314
|
+
html += '<div class="detail-section">';
|
|
1315
|
+
html += '<div class="detail-label">Tool Proxy</div>';
|
|
1316
|
+
html += '<div class="detail-grid">';
|
|
1317
|
+
html += '<span class="detail-key">Status</span><span>' + (nodeData.toolProxy.enabled ? '<span style="color:var(--green)">Enabled</span>' : '<span style="color:var(--text-dim)">Disabled</span>') + '</span>';
|
|
1318
|
+
if (nodeData.toolProxy.enabled && nodeData.toolProxy.allow?.length) {
|
|
1319
|
+
html += '<span class="detail-key">Allow</span><span>' + nodeData.toolProxy.allow.map(function(t) { return esc(t); }).join(', ') + '</span>';
|
|
1320
|
+
}
|
|
1321
|
+
if (nodeData.toolProxy.enabled && nodeData.toolProxy.deny?.length) {
|
|
1322
|
+
html += '<span class="detail-key">Deny</span><span>' + nodeData.toolProxy.deny.map(function(t) { return esc(t); }).join(', ') + '</span>';
|
|
1323
|
+
}
|
|
1324
|
+
html += '</div></div>';
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1182
1327
|
// Tags
|
|
1183
1328
|
if (nodeData.tags?.length) {
|
|
1184
1329
|
html += '<div class="detail-section">';
|