Improve network map with floating bubble nodes and 6-ring layout

- Replace tiny dot nodes with frosted-glass bubbles with ambient glow and float animation
- Add 6th ring for netscan-discovered network devices (cap 28)
- Split named/DB devices and discovered devices into separate rings
- Push rFrac to 0.82 to fill the overlay window
- Increase all ring caps and node radii
- Add FortiGate NAT IP to providers ACL
- Fix TCP SIP drop issue via transport=udp

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 03:26:48 +00:00
parent 549ff972cf
commit 1c3a9fd49e
+150 -74
View File
@@ -1142,8 +1142,9 @@ body::after{
<div id="nmLegend">
<span><span class="nm-leg-dot" style="background:#00ff88;box-shadow:0 0 4px #00ff88"></span>PROXMOX</span>
<span><span class="nm-leg-dot" style="background:#ffd700;box-shadow:0 0 4px #ffd700"></span>SERVICES</span>
<span><span class="nm-leg-dot" style="background:#00d4ff;box-shadow:0 0 4px #00d4ff"></span>AGENTS</span>
<span><span class="nm-leg-dot" style="background:rgba(0,140,180,0.8)"></span>DEVICES</span>
<span><span class="nm-leg-dot" style="background:#00beff;box-shadow:0 0 4px #00beff"></span>AGENTS</span>
<span><span class="nm-leg-dot" style="background:rgba(0,160,200,0.9)"></span>DEVICES</span>
<span><span class="nm-leg-dot" style="background:rgba(0,110,170,0.9)"></span>NETWORK</span>
<span><span class="nm-leg-dot" style="background:#ff2244;box-shadow:0 0 4px #ff2244"></span>OFFLINE</span>
<span style="margin-left:auto;opacity:0.4;font-size:0.5rem">CYAN = DATA IN · ORANGE = CMD OUT</span>
</div>
@@ -1582,14 +1583,14 @@ function renderTopology(devices) {
const canvas = document.getElementById('topoCanvas');
if (!canvas) return;
const W = canvas.parentElement?.clientWidth || 260;
canvas.width = W; canvas.height = 100;
canvas.width = W; canvas.height = 118;
_topoNodes = devices.slice(0, 18).map((d, i, arr) => {
const angle = (i / arr.length) * Math.PI * 2 - Math.PI / 2;
const rx = W * 0.36, ry = 36;
return {
x: W/2 + Math.cos(angle) * rx * (0.6 + (i%3)*0.18),
y: 50 + Math.sin(angle) * ry * (0.7 + (i%2)*0.3),
y: 52 + Math.sin(angle) * ry * (0.65 + (i%2)*0.25),
label: (d.name || d.ip || '?').split('.')[0].substring(0, 9),
on: !!(d.alive || d.status === 'online'),
agent: d.source === 'agent',
@@ -1609,37 +1610,62 @@ function _drawTopo() {
_topoT += 0.018;
ctx.clearRect(0, 0, W, H);
const floatY = n => Math.sin(_topoT * 0.9 + n.phase) * 2.5;
// Connections
for (let i = 0; i < _topoNodes.length; i++) {
for (let j = i+1; j < _topoNodes.length; j++) {
const a = _topoNodes[i], b = _topoNodes[j];
if (!a.on || !b.on) continue;
const dx = b.x-a.x, dy = b.y-a.y, dist = Math.hypot(dx,dy);
const ax = a.x, ay = a.y + floatY(a), bx = b.x, by = b.y + floatY(b);
const dist = Math.hypot(bx-ax, by-ay);
if (dist > W * 0.55) continue;
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y);
ctx.strokeStyle = 'rgba(0,180,220,0.13)'; ctx.lineWidth = 0.5; ctx.stroke();
const lg = ctx.createLinearGradient(ax, ay, bx, by);
const col = a.agent ? '0,255,136' : '0,212,255';
lg.addColorStop(0, `rgba(${col},0.25)`); lg.addColorStop(0.5, `rgba(${col},0.1)`); lg.addColorStop(1, `rgba(${col},0.25)`);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by);
ctx.strokeStyle = lg; ctx.lineWidth = 0.8; ctx.stroke();
// travelling pulse
const p = (_topoT * 0.4 + a.phase) % 1;
ctx.beginPath(); ctx.arc(a.x+dx*p, a.y+dy*p, 1.5, 0, Math.PI*2);
ctx.fillStyle = 'rgba(0,212,255,0.7)'; ctx.fill();
ctx.beginPath(); ctx.arc(ax+(bx-ax)*p, ay+(by-ay)*p, 1.8, 0, Math.PI*2);
ctx.fillStyle = 'rgba(0,212,255,0.85)'; ctx.fill();
}
}
// Nodes
// Bubble nodes
for (const n of _topoNodes) {
const pulse = 0.5 + Math.sin(_topoT*1.8 + n.phase) * 0.3;
const pulse = 0.5 + Math.sin(_topoT * 1.4 + n.phase) * 0.3;
const col = n.on ? (n.agent ? '0,255,136' : '0,212,255') : '255,50,80';
const a = n.on ? pulse * 0.7 : 0.2;
const r = n.agent ? 10 : 7;
const nx = n.x, ny = n.y + floatY(n);
if (n.on) {
const g = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,9);
g.addColorStop(0,`rgba(${col},${(a*0.5).toFixed(2)})`); g.addColorStop(1,`rgba(${col},0)`);
ctx.beginPath(); ctx.arc(n.x,n.y,9,0,Math.PI*2); ctx.fillStyle=g; ctx.fill();
// Ambient bloom
const bloom = ctx.createRadialGradient(nx, ny, r*0.4, nx, ny, r*3);
bloom.addColorStop(0, `rgba(${col},${(pulse*0.18).toFixed(3)})`);
bloom.addColorStop(1, `rgba(${col},0)`);
ctx.beginPath(); ctx.arc(nx, ny, r*3, 0, Math.PI*2); ctx.fillStyle = bloom; ctx.fill();
}
ctx.beginPath(); ctx.arc(n.x,n.y, n.agent ? 4 : 3, 0, Math.PI*2);
ctx.fillStyle = `rgba(${col},${a.toFixed(2)})`; ctx.fill();
ctx.font = '6.5px Share Tech Mono,monospace';
ctx.fillStyle = n.on ? 'rgba(180,230,255,0.65)' : 'rgba(255,100,100,0.45)';
ctx.fillText(n.label, n.x+5, n.y+3);
// Frosted glass fill
const fg = ctx.createRadialGradient(nx, ny - r*0.3, 0, nx, ny, r);
const fa = n.on ? 0.18 + pulse*0.1 : 0.07;
fg.addColorStop(0, `rgba(${col},${(fa*1.8).toFixed(3)})`);
fg.addColorStop(0.65, `rgba(${col},${fa.toFixed(3)})`);
fg.addColorStop(1, `rgba(${col},${(fa*0.2).toFixed(3)})`);
ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2); ctx.fillStyle = fg; ctx.fill();
// Border
ctx.beginPath(); ctx.arc(nx, ny, r, 0, Math.PI*2);
ctx.strokeStyle = `rgba(${col},${n.on ? (0.5 + pulse*0.32).toFixed(3) : '0.2'})`;
ctx.lineWidth = 1; ctx.stroke();
// Label below bubble
ctx.font = '6px Share Tech Mono,monospace';
ctx.textAlign = 'center';
ctx.fillStyle = n.on ? `rgba(${col},0.82)` : 'rgba(255,100,100,0.5)';
ctx.fillText(n.label, nx, ny + r + 7);
ctx.textAlign = 'left';
}
}
@@ -1841,32 +1867,39 @@ function _focusWindow() {
// ── NETWORK MAP ──────────────────────────────────────────────────────────────
var _nmNodes=[], _nmEdges=[], _nmParticles=[], _nmRaf=null, _nmT=0, _nmHoverNode=null;
var _nmRot=[0,0,0,0,0];
var _nmRot=[0,0,0,0,0,0];
var NM_RINGS=[
{name:'hub', label:'', rFrac:0, speed:0, rgb:'0,212,255', nodeR:20, cap:1 },
{name:'proxmox', label:'PROXMOX', rFrac:0.18, speed:0.006, rgb:'0,255,136', nodeR:13, cap:4 },
{name:'services',label:'SERVICES',rFrac:0.33, speed:-0.004, rgb:'255,215,0', nodeR:12, cap:5 },
{name:'agents', label:'AGENTS', rFrac:0.50, speed:0.0025, rgb:'0,190,255', nodeR:10, cap:8 },
{name:'devices', label:'NETWORK', rFrac:0.68, speed:-0.002, rgb:'0,140,180', nodeR:7, cap:9 },
{name:'hub', label:'', rFrac:0, speed:0, rgb:'0,212,255', nodeR:30, cap:1 },
{name:'proxmox', label:'PROXMOX', rFrac:0.16, speed:0.006, rgb:'0,255,136', nodeR:22, cap:4 },
{name:'services',label:'SERVICES', rFrac:0.30, speed:-0.004, rgb:'255,215,0', nodeR:20, cap:7 },
{name:'agents', label:'AGENTS', rFrac:0.45, speed:0.0025, rgb:'0,190,255', nodeR:17, cap:12 },
{name:'devices', label:'DEVICES', rFrac:0.62, speed:-0.002, rgb:'0,160,200', nodeR:14, cap:14 },
{name:'network', label:'NETWORK', rFrac:0.82, speed:0.0015, rgb:'0,110,170', nodeR:11, cap:28 },
];
var NM_OPEN_RE = /\b(show|open|display|launch|pull\s*up|bring\s*up)\b.*\b(network\s*(map|topology|viz|visual|graph)|topology|node\s*map)\b|\bnetwork\s*(map|topology|viz|visual|graph)\b/i;
var NM_CLOSE_RE = /\b(close|hide|dismiss|exit|collapse)\b.*\b(network|map|topology|overlay)\b|\b(close|hide|dismiss)\s*map\b/i;
function _nmClassify(d){
var h=(d.name||'').toLowerCase();
if(d.source!=='agent') return 'devices';
if(d.agent_type==='proxmox'||h.indexOf('pve')>=0||h.indexOf('proxmox')>=0) return 'proxmox';
if(d.agent_type==='homeassistant'||h.indexOf('homeassist')>=0||h.indexOf('_ha')>=0||
h.indexOf('ollama')>=0||h.indexOf('ai')>=0||h.indexOf('fusion')>=0||h.indexOf('pbx')>=0||
h.indexOf('jellyfin')>=0||h.indexOf('homebridge')>=0) return 'services';
return 'agents';
if(d.source==='agent'){
if(d.agent_type==='proxmox'||h.indexOf('pve')>=0||h.indexOf('proxmox')>=0) return 'proxmox';
if(d.agent_type==='homeassistant'||h.indexOf('homeassist')>=0||h.indexOf('_ha')>=0||
h.indexOf('ollama')>=0||h.indexOf('ai')>=0||h.indexOf('fusion')>=0||h.indexOf('pbx')>=0||
h.indexOf('jellyfin')>=0||h.indexOf('homebridge')>=0) return 'services';
return 'agents';
}
// Named/pinned DB devices and static hosts get the inner device ring
if(d.source==='db'||d.source==='static') return 'devices';
// Netscan-discovered devices go to the outer network ring
return 'network';
}
function _nmRgb(n){
if(!n.online) return '255,50,80';
if(n.ringIdx===1) return '0,255,136';
if(n.ringIdx===2) return '255,215,0';
if(n.ringIdx===3) return '0,190,255';
if(n.ringIdx===4) return '0,140,180';
if(n.ringIdx===4) return '0,160,200';
if(n.ringIdx===5) return '0,110,170';
return '0,212,255';
}
function _nmNodePos(n,W,H){
@@ -1892,11 +1925,19 @@ function closeNetMap(){
function _nmBuild(devices){
_nmNodes=[]; _nmEdges=[]; _nmParticles=[];
// Hub
_nmNodes.push({id:'jarvis',label:'JARVIS',sub:'165.22.1.228',online:true,agent:true,ringIdx:0,angle:0,r:20,pulse:0});
_nmNodes.push({id:'jarvis',label:'JARVIS',sub:'165.22.1.228',online:true,agent:true,ringIdx:0,angle:0,r:NM_RINGS[0].nodeR,pulse:0});
// Bucket
var buckets={proxmox:[],services:[],agents:[],devices:[]};
var buckets={proxmox:[],services:[],agents:[],devices:[],network:[]};
for(var i=0;i<devices.length;i++) buckets[_nmClassify(devices[i])].push(devices[i]);
var rings=['proxmox','services','agents','devices'];
// Sort netscan devices: online first, then those with meaningful hostnames
buckets.network.sort(function(a,b){
var sa=a.alive?1:0, sb=b.alive?1:0;
if(sb!==sa) return sb-sa;
var ha=(a.name&&a.name!==a.ip&&a.name.indexOf('10.48')!==0)?1:0;
var hb=(b.name&&b.name!==b.ip&&b.name.indexOf('10.48')!==0)?1:0;
return hb-ha;
});
var rings=['proxmox','services','agents','devices','network'];
for(var ri=0;ri<rings.length;ri++){
var rname=rings[ri], rd=NM_RINGS[ri+1], list=buckets[rname].slice(0,rd.cap);
for(var j=0;j<list.length;j++){
@@ -1935,7 +1976,7 @@ function _nmDraw(){
var canvas=document.getElementById('nmCanvas'); if(!canvas) return;
var ov=document.getElementById('netMapOverlay');
canvas.width = ov ? ov.clientWidth : 820;
canvas.height= ov ? ov.clientHeight-62 : 478;
canvas.height= ov ? ov.clientHeight-48 : 490;
var W=canvas.width, H=canvas.height, cx=W/2, cy=H/2, minR=Math.min(cx,cy);
var ctx=canvas.getContext('2d');
@@ -1980,9 +2021,14 @@ function _nmDraw(){
for(var ei=0;ei<_nmEdges.length;ei++){
var e=_nmEdges[ei], pa=pos[e.from], pb=pos[e.to]; if(!pa||!pb) continue;
var n=_nmNodes[e.from], rgb=_nmRgb(n);
ctx.beginPath();ctx.moveTo(pa.x,pa.y);ctx.lineTo(pb.x,pb.y);
ctx.strokeStyle=n.online?'rgba('+rgb+',0.1)':'rgba(80,20,30,0.07)';
ctx.lineWidth=e.strength*0.9; ctx.stroke();
// Apply float offset to spoke endpoints
var fya=Math.sin(_nmT*0.85+_nmNodes[e.from].pulse)*4;
var fyb=Math.sin(_nmT*0.85+_nmNodes[e.to].pulse)*4;
var lg=ctx.createLinearGradient(pa.x,pa.y+fya,pb.x,pb.y+fyb);
if(n.online){lg.addColorStop(0,'rgba('+rgb+',0.22)');lg.addColorStop(0.5,'rgba('+rgb+',0.08)');lg.addColorStop(1,'rgba(0,212,255,0.15)');}
else{lg.addColorStop(0,'rgba(80,20,30,0.07)');lg.addColorStop(1,'rgba(80,20,30,0.07)');}
ctx.beginPath();ctx.moveTo(pa.x,pa.y+fya);ctx.lineTo(pb.x,pb.y+fyb);
ctx.strokeStyle=lg;ctx.lineWidth=e.strength*1.1;ctx.stroke();
}
// Particles
@@ -2000,47 +2046,77 @@ function _nmDraw(){
ctx.shadowBlur=6;ctx.fill();ctx.shadowBlur=0;
}
// Nodes
// Bubble nodes
for(var ni=0;ni<_nmNodes.length;ni++){
var n=_nmNodes[ni], p=pos[ni], rgb=_nmRgb(n);
var pulse=0.5+Math.sin(_nmT*1.5+n.pulse)*0.3;
var isHub=n.ringIdx===0, isHov=_nmHoverNode===ni, baseR=n.r+(isHov?5:0);
// Glow
var pulse=0.5+Math.sin(_nmT*1.4+n.pulse)*0.3;
var isHub=n.ringIdx===0, isHov=_nmHoverNode===ni;
// Float offset — each node drifts on Y with unique phase
var fy=Math.sin(_nmT*0.85+n.pulse)*4;
var px=p.x, py=p.y+fy;
var baseR=n.r*(isHov?1.28:1.0);
// Ambient glow bloom
if(n.online){
var gr=baseR+10+Math.sin(_nmT+n.pulse)*3;
var g=ctx.createRadialGradient(p.x,p.y,baseR*0.3,p.x,p.y,gr);
g.addColorStop(0,'rgba('+rgb+','+(pulse*0.22).toFixed(3)+')');g.addColorStop(1,'rgba('+rgb+',0)');
ctx.beginPath();ctx.arc(p.x,p.y,gr,0,Math.PI*2);ctx.fillStyle=g;ctx.fill();
// Rotating arc
var arcSpd=isHub?0.6:0.38,arcLen=isHub?Math.PI*1.5:Math.PI*0.85;
ctx.beginPath();ctx.arc(p.x,p.y,baseR+4,_nmT*arcSpd,_nmT*arcSpd+arcLen);
ctx.strokeStyle='rgba('+rgb+','+(0.2+pulse*0.15).toFixed(3)+')';ctx.lineWidth=isHub?1.5:0.8;ctx.stroke();
if(isHub){ctx.beginPath();ctx.arc(p.x,p.y,baseR+8,-_nmT*0.35,-_nmT*0.35+Math.PI*1.1);ctx.strokeStyle='rgba('+rgb+',0.12)';ctx.lineWidth=0.6;ctx.stroke();}
var bloomR=baseR*2.6+Math.sin(_nmT*1.1+n.pulse)*3;
var bloom=ctx.createRadialGradient(px,py,baseR*0.4,px,py,bloomR);
bloom.addColorStop(0,'rgba('+rgb+','+(pulse*0.2).toFixed(3)+')');
bloom.addColorStop(1,'rgba('+rgb+',0)');
ctx.beginPath();ctx.arc(px,py,bloomR,0,Math.PI*2);ctx.fillStyle=bloom;ctx.fill();
// Sonar ping — expanding ring that fades out
var pingR=baseR+(((_nmT*0.6+n.pulse)%1)*baseR*2.5);
var pingA=(1-(pingR-baseR)/(baseR*2.5))*0.3;
ctx.beginPath();ctx.arc(px,py,pingR,0,Math.PI*2);
ctx.strokeStyle='rgba('+rgb+','+pingA.toFixed(3)+')';
ctx.lineWidth=0.7;ctx.stroke();
}
// Fill + border
var fg=ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,baseR);
var fa=n.online?pulse*0.4:0.09;
fg.addColorStop(0,'rgba('+rgb+','+fa.toFixed(3)+')');fg.addColorStop(1,'rgba('+rgb+','+(fa*0.2).toFixed(3)+')');
ctx.beginPath();ctx.arc(p.x,p.y,baseR,0,Math.PI*2);ctx.fillStyle=fg;ctx.fill();
ctx.strokeStyle='rgba('+rgb+','+(n.online?0.58+pulse*0.3:0.18).toFixed(3)+')';ctx.lineWidth=isHub?2:1;ctx.stroke();
// Hub crosshairs
// Frosted glass fill
var fg=ctx.createRadialGradient(px,py-baseR*0.28,0,px,py,baseR);
var fa=n.online?0.17+pulse*0.1:0.06;
fg.addColorStop(0,'rgba('+rgb+','+(fa*2.0).toFixed(3)+')');
fg.addColorStop(0.55,'rgba('+rgb+','+fa.toFixed(3)+')');
fg.addColorStop(1,'rgba('+rgb+','+(fa*0.15).toFixed(3)+')');
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);ctx.fillStyle=fg;ctx.fill();
// Glassy highlight sheen (top-left arc)
if(n.online){
var sh=ctx.createRadialGradient(px-baseR*0.3,py-baseR*0.35,0,px,py,baseR);
sh.addColorStop(0,'rgba(255,255,255,0.12)');sh.addColorStop(0.45,'rgba(255,255,255,0.03)');sh.addColorStop(1,'rgba(255,255,255,0)');
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);ctx.fillStyle=sh;ctx.fill();
}
// Border
ctx.beginPath();ctx.arc(px,py,baseR,0,Math.PI*2);
ctx.strokeStyle='rgba('+rgb+','+(n.online?(0.45+pulse*0.35).toFixed(3):'0.18')+')';
ctx.lineWidth=isHub?1.8:1.1;ctx.stroke();
// Hub crosshairs (softer)
if(isHub){
ctx.strokeStyle='rgba('+rgb+',0.2)';ctx.lineWidth=0.6;
var lines=[[p.x-42,p.y,p.x-baseR-3,p.y],[p.x+baseR+3,p.y,p.x+42,p.y],[p.x,p.y-42,p.x,p.y-baseR-3],[p.x,p.y+baseR+3,p.x,p.y+42]];
for(var li=0;li<lines.length;li++){ctx.beginPath();ctx.moveTo(lines[li][0],lines[li][1]);ctx.lineTo(lines[li][2],lines[li][3]);ctx.stroke();}
ctx.strokeStyle='rgba('+rgb+',0.15)';ctx.lineWidth=0.6;
var ext=50;
var hlines=[[px-ext,py,px-baseR-3,py],[px+baseR+3,py,px+ext,py],[px,py-ext,px,py-baseR-3],[px,py+baseR+3,px,py+ext]];
for(var li=0;li<hlines.length;li++){ctx.beginPath();ctx.moveTo(hlines[li][0],hlines[li][1]);ctx.lineTo(hlines[li][2],hlines[li][3]);ctx.stroke();}
}
// Status dot
if(!isHub){ctx.beginPath();ctx.arc(p.x+baseR*0.65,p.y-baseR*0.65,2.2,0,Math.PI*2);ctx.fillStyle=n.online?'rgba(0,255,120,0.9)':'rgba(255,50,80,0.9)';ctx.fill();}
// Label outward
var la=isHub?-Math.PI/2:Math.atan2(p.y-cy,p.x-cx);
var ld=baseR+(isHub?14:10);
var lx=p.x+Math.cos(la)*ld, ly=p.y+Math.sin(la)*ld+3;
ctx.font=(isHub?'700 10':'7.5')+'px Share Tech Mono,monospace';
var ca=Math.cos(la);
ctx.textAlign=isHub?'center':ca>0.2?'left':ca<-0.2?'right':'center';
ctx.fillStyle=n.online?'rgba('+rgb+',0.92)':'rgba(200,80,80,0.65)';
ctx.fillText(n.label,lx,ly);
if(n.sub&&(isHub||isHov)){ctx.font='6px Share Tech Mono,monospace';ctx.fillStyle='rgba(130,185,205,0.5)';ctx.fillText(n.sub,lx,ly+10);}
if(!isHub){
ctx.beginPath();ctx.arc(px+baseR*0.62,py-baseR*0.62,2.5,0,Math.PI*2);
ctx.fillStyle=n.online?'rgba(0,255,120,0.95)':'rgba(255,50,80,0.95)';ctx.fill();
}
// Label — centered below bubble
var lblY=py+baseR+11;
ctx.font=(isHub?'700 11':'8')+'px Share Tech Mono,monospace';
ctx.textAlign='center';
ctx.fillStyle=n.online?'rgba('+rgb+',0.95)':'rgba(220,90,90,0.7)';
ctx.fillText(n.label,px,lblY);
if(n.sub&&(isHub||isHov)){
ctx.font='6.5px Share Tech Mono,monospace';
ctx.fillStyle='rgba(140,195,215,0.55)';
ctx.fillText(n.sub,px,lblY+10);
}
ctx.textAlign='left';
}
}