diff --git a/public_html/index.html b/public_html/index.html
index 8e79bb4..19eb24c 100644
--- a/public_html/index.html
+++ b/public_html/index.html
@@ -757,16 +757,18 @@ body::after{
/* ── NETWORK MAP OVERLAY ─────────────────────────────────────────────── */
#netMapOverlay{
- position:fixed;top:48px;left:0;
- width:100vw;height:calc(100vh - 80px);
+ position:fixed;top:52px;left:0;
+ width:min(860px,86vw);
+ height:min(570px,76vh);
z-index:200;
display:none;flex-direction:column;
- background:rgba(0,4,16,0.97);
- border-right:1px solid rgba(0,212,255,0.35);
- border-bottom:1px solid rgba(0,212,255,0.35);
+ background:rgba(0,4,18,0.96);
+ border:1px solid rgba(0,212,255,0.28);
+ border-top:none;border-left:none;
transform-origin:0 0;
- backdrop-filter:blur(12px);
+ backdrop-filter:blur(14px);
overflow:hidden;
+ box-shadow:6px 6px 40px rgba(0,0,0,0.75),0 0 50px rgba(0,212,255,0.05);
}
#netMapOverlay::after{
content:'';position:absolute;inset:0;pointer-events:none;z-index:1;
@@ -1770,136 +1772,87 @@ function closeNetMap() {
}
-// Zone definitions — quadrant positions, labels, colors, max visible nodes
-const NM_ZONES = {
- proxmox: { label:'PROXMOX CLUSTER', qx:-1, qy:-1, rgb:'0,255,136', nodeR:14, maxNodes:6 },
- services: { label:'SERVICES', qx: 1, qy:-1, rgb:'255,215,0', nodeR:13, maxNodes:6 },
- agents: { label:'AGENTS', qx:-1, qy: 1, rgb:'0,212,255', nodeR:11, maxNodes:8 },
- devices: { label:'NETWORK DEVICES', qx: 1, qy: 1, rgb:'0,160,200', nodeR: 8, maxNodes:10 },
-};
+// Orbital ring definitions — each ring has its own radius ratio, speed, color, node cap
+const 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.34, speed:-0.004, rgb:'255,215,0', nodeR:12, cap:5 },
+ { name:'agents', label:'AGENTS', rFrac:0.52, speed: 0.0025, rgb:'0,190,255', nodeR:10, cap:8 },
+ { name:'devices', label:'NETWORK', rFrac:0.70, speed:-0.0015, rgb:'0,140,180', nodeR: 7, cap:10 },
+];
function _classifyDevice(d) {
const h = (d.name||'').toLowerCase();
if (d.source !== 'agent') return 'devices';
- if (d.agent_type==='proxmox' || h.includes('pve') || h.includes('proxmox')) return 'proxmox';
- if (d.agent_type==='homeassistant'|| h.includes('homeassist') || h.includes('_ha') ||
+ if (d.agent_type==='proxmox' || h.includes('pve') || h.includes('proxmox')) return 'proxmox';
+ if (d.agent_type==='homeassistant' || h.includes('homeassist') || h.includes('_ha') ||
h.includes('ollama') || h.includes('ai') || h.includes('fusion') || h.includes('pbx') ||
h.includes('jellyfin') || h.includes('homebridge')) return 'services';
return 'agents';
}
-function _nodeTypeFromZone(d, zone) {
- const h = (d.name||'').toLowerCase();
- if (zone === 'proxmox') return 'proxmox';
- if (h.includes('homeassist') || h.includes('_ha')) return 'ha';
- if (h.includes('ollama') || h.includes('ai')) return 'ai';
- if (h.includes('fusion') || h.includes('pbx')) return 'pbx';
- if (zone === 'devices') return 'device';
- return 'agent';
+function _nodeRgb(n) {
+ if (n.ring === 'hub') return '0,212,255';
+ if (n.ring === 'proxmox') return '0,255,136';
+ if (n.ring === 'services') return '255,215,0';
+ if (n.ring === 'agents') return '0,190,255';
+ if (!n.online) return '255,50,80';
+ return '0,140,180';
}
+// Ring rotation offsets (persist across redraws)
+const _nmRot = [0, 0, 0, 0, 0];
+
function _buildNetGraph(devices) {
- const canvas = document.getElementById('nmCanvas');
- const W = canvas?.clientWidth || window.innerWidth;
- const H = canvas?.clientHeight || (window.innerHeight - 130);
+ const ov = document.getElementById('netMapOverlay');
+ const W = ov ? ov.clientWidth : 860;
+ const H = (ov ? ov.clientHeight : 570) - 70; // minus header+legend
const cx = W / 2, cy = H / 2;
_nmNodes = []; _nmEdges = []; _nmParticles = [];
+ const cx = W/2, cy = H/2, minDim = Math.min(cx, cy);
- // Hub
- _nmNodes.push({ id:'jarvis', label:'JARVIS', sub:'165.22.1.228 · DO',
- type:'hub', online:true, agent:true, x:cx, y:cy, r:22, pulse:0, zone:'hub' });
+ // Hub at center
+ _nmNodes.push({ id:'jarvis', label:'JARVIS', sub:'165.22.1.228',
+ type:'hub', online:true, agent:true, ring:'hub', angle:0, r:20, pulse:0, ringIdx:0 });
- // Bucket devices into zones
+ // Bucket into rings
const buckets = { proxmox:[], services:[], agents:[], devices:[] };
- devices.forEach(d => { const z = _classifyDevice(d); buckets[z].push(d); });
-
- // Place each zone's nodes in their quadrant
- Object.entries(NM_ZONES).forEach(([zoneName, zd]) => {
- const list = buckets[zoneName] || [];
- const visible = list.slice(0, zd.maxNodes);
- const overflow = list.length - visible.length;
-
- // Zone anchor: pull toward quadrant corner, leaving breathing room from edges and center
- const zAnchorX = cx + zd.qx * cx * 0.58;
- const zAnchorY = cy + zd.qy * cy * 0.60;
-
- // Arrange nodes in a tidy grid/arc within the zone
- const count = visible.length;
- visible.forEach((d, i) => {
- let nx, ny;
- if (count === 1) {
- nx = zAnchorX; ny = zAnchorY;
- } else if (count <= 3) {
- const angle = ((i / count) * Math.PI * 0.7) - Math.PI * 0.35;
- const sectorAngle = Math.atan2(zd.qy, zd.qx);
- nx = zAnchorX + Math.cos(sectorAngle + angle) * 65;
- ny = zAnchorY + Math.sin(sectorAngle + angle) * 65;
- } else {
- // Grid layout: cols × rows centered on anchor
- const cols = Math.ceil(Math.sqrt(count * 1.4));
- const row = Math.floor(i / cols), col = i % cols;
- const spacing = Math.min(72, (Math.min(cx, cy) * 0.38) / cols);
- nx = zAnchorX + (col - (cols - 1) / 2) * spacing;
- ny = zAnchorY + (row - (Math.ceil(count / cols) - 1) / 2) * spacing;
- }
-
- // Keep nodes inside canvas with generous margin
- nx = Math.max(55, Math.min(W - 55, nx + (Math.random() - 0.5) * 12));
- ny = Math.max(35, Math.min(H - 35, ny + (Math.random() - 0.5) * 12));
+ devices.forEach(d => buckets[_classifyDevice(d)].push(d));
+ ['proxmox','services','agents','devices'].forEach((ringName, ri) => {
+ const rd = NM_RINGS[ri + 1];
+ const list = buckets[ringName].slice(0, rd.cap);
+ list.forEach((d, i) => {
+ const baseAngle = (ri % 2 === 0 ? -Math.PI/2 : -Math.PI/3);
+ const angle = baseAngle + (i / Math.max(list.length, 1)) * Math.PI * 2;
_nmNodes.push({
- id: d.agent_id || d.ip || (zoneName + i),
- label: (d.name || d.ip || '?').replace(/_[a-f0-9]{6,}$/, '').substring(0, 13),
- sub: d.ip || '',
- type: _nodeTypeFromZone(d, zoneName),
- online: !!(d.alive || d.status === 'online'),
- agent: d.source === 'agent',
- x: nx, y: ny, r: zd.nodeR,
- pulse: Math.random() * Math.PI * 2,
- zone: zoneName,
- rgb: zd.rgb,
+ id: d.agent_id || d.ip || (ringName+i),
+ label: (d.name||d.ip||'?').replace(/_[a-f0-9]{6,}$/,'').substring(0,11),
+ sub: d.ip||'', type: ringName,
+ online: !!(d.alive||d.status==='online'),
+ agent: d.source==='agent',
+ ring: ringName, ringIdx: ri+1,
+ angle, r: rd.nodeR, pulse: Math.random()*Math.PI*2,
});
});
-
- // Overflow indicator node ("+N more")
- if (overflow > 0) {
- const zAnchorX2 = cx + zd.qx * cx * 0.68;
- const zAnchorY2 = cy + zd.qy * cy * 0.73;
- _nmNodes.push({
- id: zoneName+'_overflow', label:'+'+overflow+' MORE', sub:'',
- type:'overflow', online:false, agent:false,
- x: Math.max(40, Math.min(W-40, zAnchorX2)),
- y: Math.max(30, Math.min(H-30, zAnchorY2)),
- r: 9, pulse: 0, zone: zoneName, rgb: zd.rgb,
- });
- }
});
- // Edges: every non-hub node → hub
+ // Edges: each node → hub
for (let i = 1; i < _nmNodes.length; i++) {
const n = _nmNodes[i];
- if (n.type === 'overflow') continue;
- _nmEdges.push({ from:i, to:0, strength: n.agent ? 1 : 0.45, zoneName: n.zone });
+ _nmEdges.push({ from:i, to:0, strength: n.agent ? 0.9 : 0.45 });
}
- // Cross-link within proxmox cluster
- const pveIdx = _nmNodes.map((n,i)=>({n,i})).filter(({n})=>n.zone==='proxmox'&&n.type!=='overflow');
- for (let a = 0; a < pveIdx.length; a++)
- for (let b = a+1; b < pveIdx.length; b++)
- _nmEdges.push({ from:pveIdx[a].i, to:pveIdx[b].i, strength:0.55, intra:true });
- // Cross-link within services cluster
- const svcIdx = _nmNodes.map((n,i)=>({n,i})).filter(({n})=>n.zone==='services'&&n.type!=='overflow');
- for (let a = 0; a < svcIdx.length; a++)
- for (let b = a+1; b < svcIdx.length; b++)
- _nmEdges.push({ from:svcIdx[a].i, to:svcIdx[b].i, strength:0.35, intra:true });
- // Spawn particles for hub edges
- _nmEdges.filter(e => !e.intra).forEach((e, ei) => {
- const cnt = e.strength > 0.8 ? 4 : 2;
- for (let p = 0; p < cnt; p++) {
- _nmParticles.push({ edge:ei, t:Math.random(), dir:'in', speed:0.0025+Math.random()*0.003, r:2+Math.random()*0.8 });
- if (e.strength > 0.6 && Math.random() > 0.5)
- _nmParticles.push({ edge:ei, t:Math.random(), dir:'out', speed:0.0016+Math.random()*0.002, r:1.6+Math.random()*0.7 });
- }
+ // Particles
+ _nmEdges.forEach((e, ei) => {
+ const n = _nmNodes[e.from];
+ const cnt = n.online ? (n.agent ? 3 : 2) : 0;
+ for (let p = 0; p < cnt; p++)
+ _nmParticles.push({ edge:ei, t:Math.random(), dir:'in',
+ speed:0.002+Math.random()*0.0025, r:1.8+Math.random()*0.9 });
+ if (n.online && n.agent && Math.random()>0.45)
+ _nmParticles.push({ edge:ei, t:Math.random(), dir:'out',
+ speed:0.0013+Math.random()*0.0017, r:1.5+Math.random()*0.7 });
});
// Stats
@@ -1911,221 +1864,187 @@ function _buildNetGraph(devices) {
if(g('nm-agent-count')) g('nm-agent-count').textContent = agt;
}
-// Bezier point — bows OUTWARD from hub center so lines don't bundle in center
-function _bezierPt(ax, ay, bx, by, t, hx, hy) {
- const mx = (ax+bx)/2, my = (ay+by)/2;
- const dx = mx-hx, dy = my-hy;
- const dist = Math.hypot(dx,dy)||1;
- const bow = 35 + dist * 0.12; // bow scales with distance from center
- const cpx = mx + (dx/dist)*bow, cpy = my + (dy/dist)*bow;
- const iv = 1-t;
- return { x: iv*iv*ax + 2*iv*t*cpx + t*t*bx, y: iv*iv*ay + 2*iv*t*cpy + t*t*by };
-}
-
function _startNetDraw() {
if (_nmRaf) cancelAnimationFrame(_nmRaf);
const canvas = document.getElementById('nmCanvas');
if (!canvas) return;
- canvas.width = canvas.clientWidth || window.innerWidth;
- canvas.height = canvas.clientHeight || (window.innerHeight - 130);
+ const ov = document.getElementById('netMapOverlay');
+ canvas.width = ov ? ov.clientWidth : 860;
+ canvas.height = (ov ? ov.clientHeight : 570) - 72;
const W = canvas.width, H = canvas.height;
- const hub = _nmNodes[0];
- const hx = hub?.x || W/2, hy = hub?.y || H/2;
+ const cx = W/2, cy = H/2, minDim = Math.min(cx, cy);
+
+ function nodePos(n) {
+ if (n.ring === 'hub') return { x:cx, y:cy };
+ const rd = NM_RINGS[n.ringIdx];
+ const rot = _nmRot[n.ringIdx] || 0;
+ const r = minDim * rd.rFrac;
+ return { x: cx + Math.cos(n.angle+rot)*r, y: cy + Math.sin(n.angle+rot)*r };
+ }
function draw() {
if (!document.getElementById('netMapOverlay')?.classList.contains('nm-open')) return;
_nmRaf = requestAnimationFrame(draw);
_nmT += 0.016;
+ NM_RINGS.forEach((rd,i) => { _nmRot[i] = (_nmRot[i]||0) + rd.speed; });
+
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
- // ── Fine grid ────────────────────────────────────────────────────
- ctx.strokeStyle = 'rgba(0,180,255,0.035)'; ctx.lineWidth = 0.5;
- for (let x = 0; x < W; x += 40) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
- for (let y = 0; y < H; y += 40) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
+ // Subtle dot grid
+ ctx.fillStyle = 'rgba(0,180,255,0.055)';
+ for (let x = 26; x < W; x += 40) for (let y = 26; y < H; y += 40) {
+ ctx.beginPath(); ctx.arc(x,y,0.7,0,Math.PI*2); ctx.fill();
+ }
- // ── Zone backgrounds + watermark labels ──────────────────────────
- Object.entries(NM_ZONES).forEach(([zoneName, zd]) => {
- // Quadrant fill
- const qx1 = zd.qx < 0 ? 0 : W/2;
- const qy1 = zd.qy < 0 ? 0 : H/2;
- const qW = W/2, qH = H/2;
- const grad = ctx.createRadialGradient(
- qx1 + (zd.qx<0?qW:0), qy1 + (zd.qy<0?qH:0), 0,
- qx1 + qW/2, qy1 + qH/2, Math.max(qW,qH)
- );
- grad.addColorStop(0, `rgba(${zd.rgb},0.04)`);
- grad.addColorStop(1, `rgba(${zd.rgb},0)`);
- ctx.fillStyle = grad;
- ctx.fillRect(qx1, qy1, qW, qH);
+ // Orbital ring tracks
+ NM_RINGS.slice(1).forEach((rd, i) => {
+ const ri = i+1, r = minDim * rd.rFrac;
+ const hasOn = _nmNodes.some(n=>n.ringIdx===ri&&n.online);
+ ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2);
+ ctx.strokeStyle = hasOn ? `rgba(${rd.rgb},0.12)` : 'rgba(60,60,80,0.08)';
+ ctx.lineWidth=0.8; ctx.setLineDash([3,9]); ctx.stroke(); ctx.setLineDash([]);
- // Section divider lines (thin, along the midpoint cross)
- ctx.strokeStyle = 'rgba(0,180,255,0.07)'; ctx.lineWidth = 1;
- ctx.setLineDash([4,6]);
- ctx.beginPath(); ctx.moveTo(W/2, 0); ctx.lineTo(W/2, H); ctx.stroke();
- ctx.beginPath(); ctx.moveTo(0, H/2); ctx.lineTo(W, H/2); ctx.stroke();
- ctx.setLineDash([]);
-
- // Zone watermark label
- const lx = qx1 + qW/2, ly = qy1 + (zd.qy < 0 ? 18 : qH - 12);
- ctx.font = '700 11px Share Tech Mono,monospace';
- ctx.textAlign = 'center';
- ctx.fillStyle = `rgba(${zd.rgb},0.18)`;
- ctx.fillText(zd.label, lx, ly);
-
- // Online/total count per zone
- const zNodes = _nmNodes.filter(n=>n.zone===zoneName&&n.type!=='overflow');
- const zOn = zNodes.filter(n=>n.online).length;
- ctx.font = '9px Share Tech Mono,monospace';
- ctx.fillStyle = `rgba(${zd.rgb},0.35)`;
- ctx.fillText(`${zOn}/${zNodes.length} ONLINE`, lx, ly + 14);
- ctx.textAlign = 'left';
- });
-
- // ── Edges ────────────────────────────────────────────────────────
- _nmEdges.forEach(e => {
- const a = _nmNodes[e.from], b = _nmNodes[e.to];
- if (!a||!b) return;
- const alive = a.online && b.online;
- ctx.beginPath();
- const steps = 50;
- for (let i = 0; i <= steps; i++) {
- const p = e.intra
- ? { x: a.x+(b.x-a.x)*(i/steps), y: a.y+(b.y-a.y)*(i/steps) }
- : _bezierPt(a.x, a.y, b.x, b.y, i/steps, hx, hy);
- i===0 ? ctx.moveTo(p.x,p.y) : ctx.lineTo(p.x,p.y);
- }
- ctx.strokeStyle = alive
- ? (e.intra ? 'rgba(255,215,0,0.18)' : 'rgba(0,180,220,0.15)')
- : 'rgba(100,20,40,0.1)';
- ctx.lineWidth = e.strength * 1.2; ctx.stroke();
- });
-
- // ── Particles ────────────────────────────────────────────────────
- _nmParticles.forEach(p => {
- p.t = (p.t + p.speed) % 1;
- const e = _nmEdges[p.edge]; if(!e) return;
- const a = _nmNodes[e.from], b = _nmNodes[e.to];
- if (!a?.online||!b?.online) return;
- const t = p.dir==='in' ? p.t : 1-p.t;
- const pt = _bezierPt(a.x, a.y, b.x, b.y, t, hx, hy);
- const fade = Math.min(t*7, (1-t)*7, 1);
- const bright = 0.55 + Math.sin(_nmT*3+p.t*8)*0.3;
- ctx.beginPath(); ctx.arc(pt.x, pt.y, p.r, 0, Math.PI*2);
- if (p.dir === 'in') {
- ctx.fillStyle = `rgba(0,212,255,${bright*fade})`;
- ctx.shadowColor='rgba(0,212,255,0.7)';
- } else {
- ctx.fillStyle = `rgba(255,140,0,${(bright*0.85)*fade})`;
- ctx.shadowColor='rgba(255,120,0,0.6)';
- }
- ctx.shadowBlur=7; ctx.fill(); ctx.shadowBlur=0;
- });
-
- // ── Nodes ────────────────────────────────────────────────────────
- _nmNodes.forEach((n, ni) => {
- if (n.type === 'overflow') {
- // Simple dimmed placeholder
- ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, Math.PI*2);
- ctx.strokeStyle = `rgba(${n.rgb||'0,180,200'},0.3)`; ctx.lineWidth=1; ctx.stroke();
- ctx.font='7px Share Tech Mono,monospace'; ctx.textAlign='center';
- ctx.fillStyle=`rgba(${n.rgb||'0,180,200'},0.5)`;
- ctx.fillText(n.label, n.x, n.y+3); ctx.textAlign='left';
- return;
- }
-
- const rgb = n.rgb || _nodeRgb(n);
- const pulse = 0.5 + Math.sin(_nmT*1.5+n.pulse)*0.3;
- const isHub = n.type==='hub';
- const isHov = _nmHoverNode===ni;
- const baseR = n.r + (isHov?5:0);
-
- // Outer glow
- if (n.online) {
- const gr = baseR + 12 + Math.sin(_nmT+n.pulse)*3;
- const g = ctx.createRadialGradient(n.x,n.y,baseR*0.4,n.x,n.y,gr);
- g.addColorStop(0,`rgba(${rgb},${pulse*0.28})`); g.addColorStop(1,`rgba(${rgb},0)`);
- ctx.beginPath(); ctx.arc(n.x,n.y,gr,0,Math.PI*2); ctx.fillStyle=g; ctx.fill();
- }
-
- // Rotating arc for agents and hub
- if (n.online && (isHub||n.agent)) {
+ // Tick marks every 30°
+ for (let a=0; an.ringIdx===ri&&n.online).length;
+ const zTot = _nmNodes.filter(n=>n.ringIdx===ri).length;
+ ctx.font='700 8px Share Tech Mono,monospace'; ctx.textAlign='left';
+ ctx.fillStyle=`rgba(${rd.rgb},0.42)`;
+ ctx.fillText(rd.label, cx+r+6, cy+2);
+ if (zTot>0) {
+ ctx.font='6.5px Share Tech Mono,monospace';
+ ctx.fillStyle=`rgba(${rd.rgb},0.28)`;
+ ctx.fillText(`${zOn}/${zTot}`, cx+r+6, cy+11);
+ }
+ ctx.textAlign='left';
+ });
+
+ // Live node positions for this frame
+ const pos = _nmNodes.map(n=>nodePos(n));
+
+ // Spoke lines
+ _nmEdges.forEach(e => {
+ const pa=pos[e.from], pb=pos[e.to]; if(!pa||!pb) return;
+ const n=_nmNodes[e.from], rgb=_nodeRgb(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();
+ });
+
+ // Particles
+ _nmParticles.forEach(p => {
+ p.t=(p.t+p.speed)%1;
+ const e=_nmEdges[p.edge]; if(!e) return;
+ const pa=pos[e.from], pb=pos[e.to]; if(!pa||!pb) return;
+ if(!_nmNodes[e.from]?.online) return;
+ const t = p.dir==='in' ? p.t : 1-p.t;
+ const px=pa.x+(pb.x-pa.x)*t, py=pa.y+(pb.y-pa.y)*t;
+ const fade=Math.min(t*8,(1-t)*8,1);
+ ctx.beginPath(); ctx.arc(px,py,p.r,0,Math.PI*2);
+ if(p.dir==='in'){
+ ctx.fillStyle=`rgba(0,210,255,${(0.6+Math.sin(_nmT*3+p.t*10)*0.3)*fade})`;
+ ctx.shadowColor='rgba(0,200,255,0.7)';
+ } else {
+ ctx.fillStyle=`rgba(255,130,0,${(0.5+Math.sin(_nmT*4+p.t*8)*0.25)*fade})`;
+ ctx.shadowColor='rgba(255,110,0,0.6)';
+ }
+ ctx.shadowBlur=6; ctx.fill(); ctx.shadowBlur=0;
+ });
+
+ // Nodes
+ _nmNodes.forEach((n,ni) => {
+ const p=pos[ni], rgb=_nodeRgb(n);
+ const pulse=0.5+Math.sin(_nmT*1.5+n.pulse)*0.3;
+ const isHub=n.ring==='hub', isHov=_nmHoverNode===ni;
+ const baseR=n.r+(isHov?5:0);
+
+ if(n.online){
+ const gr=baseR+10+Math.sin(_nmT*1.2+n.pulse)*3;
+ const grd=ctx.createRadialGradient(p.x,p.y,baseR*0.3,p.x,p.y,gr);
+ grd.addColorStop(0,`rgba(${rgb},${pulse*0.22})`); grd.addColorStop(1,`rgba(${rgb},0)`);
+ ctx.beginPath(); ctx.arc(p.x,p.y,gr,0,Math.PI*2); ctx.fillStyle=grd; ctx.fill();
+ }
+
+ // Rotating arc decoration
+ if(n.online){
+ const 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})`; 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();
+ }
}
// Fill + border
- const fg = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,baseR);
- const fa = n.online ? pulse*0.45 : 0.12;
- fg.addColorStop(0,`rgba(${rgb},${fa})`); fg.addColorStop(1,`rgba(${rgb},${fa*0.25})`);
- ctx.beginPath(); ctx.arc(n.x,n.y,baseR,0,Math.PI*2);
+ const fg=ctx.createRadialGradient(p.x,p.y,0,p.x,p.y,baseR);
+ const fa=n.online?pulse*0.4:0.09;
+ fg.addColorStop(0,`rgba(${rgb},${fa})`); fg.addColorStop(1,`rgba(${rgb},${fa*0.2})`);
+ 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.65+pulse*0.3:0.22})`; ctx.lineWidth=isHub?2:1.2; ctx.stroke();
+ ctx.strokeStyle=`rgba(${rgb},${n.online?0.58+pulse*0.3:0.18})`; ctx.lineWidth=isHub?2:1; ctx.stroke();
- // Hub cross-hairs
- if (isHub) {
- ctx.strokeStyle=`rgba(${rgb},0.22)`; ctx.lineWidth=0.6;
- [[n.x-48,n.y,n.x-baseR-4,n.y],[n.x+baseR+4,n.y,n.x+48,n.y],
- [n.x,n.y-48,n.x,n.y-baseR-4],[n.x,n.y+baseR+4,n.x,n.y+48]].forEach(([x1,y1,x2,y2])=>{
- ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke();});
+ // Hub crosshairs
+ if(isHub){
+ ctx.strokeStyle=`rgba(${rgb},0.2)`; ctx.lineWidth=0.6;
+ [[p.x-44,p.y,p.x-baseR-3,p.y],[p.x+baseR+3,p.y,p.x+44,p.y],
+ [p.x,p.y-44,p.x,p.y-baseR-3],[p.x,p.y+baseR+3,p.x,p.y+44]
+ ].forEach(([x1,y1,x2,y2])=>{ ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke(); });
}
- // Label: position OUTWARD from hub center so it never overlaps the node
- const labelAngle = isHub ? -Math.PI/2 : Math.atan2(n.y-hy, n.x-hx);
- const labelDist = baseR + (isHub?14:11);
- const lx = n.x + Math.cos(labelAngle)*labelDist;
- const ly = n.y + Math.sin(labelAngle)*labelDist + 3;
- const cosA = Math.cos(labelAngle);
- ctx.font = `${isHub?'700 10':'8'}px Share Tech Mono,monospace`;
- ctx.textAlign = isHub ? 'center' : (cosA > 0.25 ? 'left' : cosA < -0.25 ? 'right' : 'center');
- ctx.fillStyle = n.online ? `rgba(${rgb},0.95)` : 'rgba(200,80,80,0.65)';
- ctx.fillText(n.label, lx, ly);
+ // Tiny status dot on non-hub nodes
+ 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();
+ }
- // Sub (IP) only on hub or hover
- if (n.sub && (isHub||isHov)) {
- ctx.font='6.5px Share Tech Mono,monospace';
- ctx.fillStyle='rgba(150,200,220,0.5)';
- ctx.fillText(n.sub, lx, ly+(isHub?11:10));
+ // Label — outward from center
+ const labelAngle=isHub?-Math.PI/2:Math.atan2(p.y-cy,p.x-cx);
+ const lDist=baseR+(isHub?14:10);
+ const lx=p.x+Math.cos(labelAngle)*lDist, ly=p.y+Math.sin(labelAngle)*lDist+3;
+ ctx.font=`${isHub?'700 10':'7.5'}px Share Tech Mono,monospace`;
+ const ca=Math.cos(labelAngle);
+ 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);
}
ctx.textAlign='left';
});
}
draw();
- // Hover detection
- canvas.onmousemove = function(e) {
- const r = canvas.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top;
+ canvas.onmousemove=function(e){
+ const r=canvas.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top;
+ const positions=_nmNodes.map(n=>nodePos(n));
let found=-1;
- _nmNodes.forEach((n,i)=>{ if(n.type!=='overflow'&&Math.hypot(n.x-mx,n.y-my)=0?found:null;
- const info=document.getElementById('nmNodeInfo');
- if(!info) return;
+ positions.forEach((p,i)=>{ if(Math.hypot(p.x-mx,p.y-my)<_nmNodes[i].r+10) found=i; });
+ _nmHoverNode=found>=0?found:null;
+ const info=document.getElementById('nmNodeInfo'); if(!info) return;
if(found>=0){
- const n=_nmNodes[found], rgb=n.rgb||_nodeRgb(n);
+ const n=_nmNodes[found], rgb=_nodeRgb(n);
document.getElementById('ni-name').textContent=n.label;
document.getElementById('ni-name').style.color=`rgb(${rgb})`;
document.getElementById('ni-ip').textContent='IP: '+(n.sub||'—');
document.getElementById('ni-status').textContent='STATUS: '+(n.online?'ONLINE':'OFFLINE');
- document.getElementById('ni-type').textContent='TYPE: '+n.type.toUpperCase();
- info.style.display='block';
- info.style.left=(mx+16)+'px'; info.style.top=(my-8)+'px';
+ document.getElementById('ni-type').textContent='TYPE: '+n.ring.toUpperCase();
+ info.style.display='block'; info.style.left=(mx+16)+'px'; info.style.top=(my-6)+'px';
} else { info.style.display='none'; }
};
canvas.onmouseleave=()=>{ _nmHoverNode=null; const i=document.getElementById('nmNodeInfo'); if(i) i.style.display='none'; };
}
-// Fallback RGB if node has no zone-assigned rgb
-function _nodeRgb(n) {
- if (n.type==='hub') return '0,212,255';
- if (n.type==='proxmox') return '0,255,136';
- if (n.type==='ha'||n.type==='ai') return '255,215,0';
- if (n.type==='pbx') return '255,140,0';
- if (!n.online) return '255,50,80';
- return '0,180,220';
-}
-
// ── GLOBALS ──────────────────────────────────────────────────────────
let sessionToken = '';
let sessionUser = '';