diff --git a/public_html/index.html b/public_html/index.html
index e74dd08..8e79bb4 100644
--- a/public_html/index.html
+++ b/public_html/index.html
@@ -1769,288 +1769,361 @@ function closeNetMap() {
if (_nmRaf) { cancelAnimationFrame(_nmRaf); _nmRaf = null; }
}
-function _nodeColor(n) {
- if (n.type === 'hub') return {r:0, g:212, b:255};
- if (n.type === 'proxmox') return {r:0, g:255, b:136};
- if (n.type === 'ha') return {r:255,g:215, b:0 };
- if (n.type === 'ai') return {r:255,g:215, b:0 };
- if (n.type === 'pbx') return {r:255,g:140, b:0 };
- if (!n.online) return {r:255,g:34, b:68 };
- if (n.agent) return {r:0, g:212, b:255};
- return {r:0, g:160, b:200};
+
+// 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 },
+};
+
+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') ||
+ 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 _buildNetGraph(devices) {
- const W = document.getElementById('nmCanvas')?.clientWidth || window.innerWidth;
- const H = document.getElementById('nmCanvas')?.clientHeight || (window.innerHeight - 130);
-
+ const canvas = document.getElementById('nmCanvas');
+ const W = canvas?.clientWidth || window.innerWidth;
+ const H = canvas?.clientHeight || (window.innerHeight - 130);
const cx = W / 2, cy = H / 2;
_nmNodes = []; _nmEdges = []; _nmParticles = [];
- // Hub node: JARVIS
- _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, vx:0, vy:0 });
+ // 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' });
- // Categorise devices
- const agents = devices.filter(d => d.source === 'agent');
- const scanned = devices.filter(d => d.source !== 'agent');
+ // Bucket devices into zones
+ const buckets = { proxmox:[], services:[], agents:[], devices:[] };
+ devices.forEach(d => { const z = _classifyDevice(d); buckets[z].push(d); });
- // Assign node types from hostname/agent_type
- function classifyAgent(d) {
- const h = (d.name||'').toLowerCase();
- if (d.agent_type==='proxmox' || h.includes('pve') || h.includes('proxmox')) return 'proxmox';
- if (d.agent_type==='homeassistant' || 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';
- return 'agent';
- }
+ // 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;
- // Place agents in inner ring, scanned in outer ring
- const innerR = Math.min(cx, cy) * 0.40;
- const outerR = Math.min(cx, cy) * 0.72;
+ // 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;
- agents.forEach((d, i) => {
- const a = (i / Math.max(agents.length, 1)) * Math.PI * 2 - Math.PI / 2;
- const jitter = (Math.random() - 0.5) * 30;
- _nmNodes.push({
- id: d.agent_id || d.ip || ('a'+i),
- label: (d.name || d.ip || '?').replace(/_[a-f0-9]{6,}$/, '').substring(0, 14),
- sub: d.ip || '',
- type: classifyAgent(d),
- online: !!(d.alive || d.status === 'online'),
- agent: true,
- x: cx + Math.cos(a) * (innerR + jitter),
- y: cy + Math.sin(a) * (innerR + jitter),
- r: 13, pulse: Math.random() * Math.PI * 2, vx: 0, vy: 0,
- });
- });
-
- scanned.forEach((d, i) => {
- const a = (i / Math.max(scanned.length, 1)) * Math.PI * 2 - Math.PI / 4;
- const jitter = (Math.random() - 0.5) * 25;
- _nmNodes.push({
- id: d.ip || ('s'+i),
- label: (d.name || d.ip || '?').substring(0, 12),
- sub: d.ip || '',
- type: 'device',
- online: !!(d.alive || d.status === 'online'),
- agent: false,
- x: cx + Math.cos(a) * (outerR + jitter),
- y: cy + Math.sin(a) * (outerR + jitter),
- r: 7, pulse: Math.random() * Math.PI * 2, vx: 0, vy: 0,
- });
- });
-
- // Build edges (all nodes → hub; proxmox nodes interconnect)
- const hub = _nmNodes[0];
- for (let i = 1; i < _nmNodes.length; i++) {
- const n = _nmNodes[i];
- if (!n.online && !hub.online) continue;
- _nmEdges.push({ from: i, to: 0, strength: n.agent ? 1 : 0.4 });
- }
- // Cross-link proxmox nodes
- const pveNodes = _nmNodes.map((n,i)=>({n,i})).filter(({n})=>n.type==='proxmox');
- for (let a = 0; a < pveNodes.length; a++)
- for (let b = a+1; b < pveNodes.length; b++)
- _nmEdges.push({ from: pveNodes[a].i, to: pveNodes[b].i, strength: 0.6 });
-
- // Spawn particles for each edge
- _nmEdges.forEach((e, ei) => {
- const count = e.strength > 0.8 ? 5 : (e.strength > 0.5 ? 3 : 2);
- for (let p = 0; p < count; p++) {
- // Cyan particles: node → hub (data in)
- _nmParticles.push({ edge: ei, t: Math.random(), dir: 'in',
- speed: 0.003 + Math.random() * 0.003, r: 2.2 + Math.random() });
- // Orange particles: hub → node (commands out) — fewer, slower
- if (e.strength > 0.6 && Math.random() > 0.4) {
- _nmParticles.push({ edge: ei, t: Math.random(), dir: 'out',
- speed: 0.0018 + Math.random() * 0.002, r: 1.8 + Math.random() * 0.8 });
+ // 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));
+
+ _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,
+ });
+ });
+
+ // 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,
+ });
}
});
- // Update stats bar
+ // Edges: every non-hub 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 });
+ }
+ // 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 });
+ }
+ });
+
+ // Stats
const online = _nmNodes.filter(n=>n.online).length;
- const agentCount = _nmNodes.filter(n=>n.agent).length;
- const el = id => document.getElementById(id);
- if(el('nm-node-count')) el('nm-node-count').textContent = _nmNodes.length;
- if(el('nm-online-count')) el('nm-online-count').textContent = online;
- if(el('nm-agent-count')) el('nm-agent-count').textContent = agentCount;
+ const agt = _nmNodes.filter(n=>n.agent).length;
+ const g = id => document.getElementById(id);
+ if(g('nm-node-count')) g('nm-node-count').textContent = _nmNodes.length;
+ if(g('nm-online-count')) g('nm-online-count').textContent = online;
+ if(g('nm-agent-count')) g('nm-agent-count').textContent = agt;
}
-function _bezierPt(ax, ay, bx, by, t) {
- // Cubic bezier with perpendicular control points for curved lines
- const mx = (ax + bx) / 2, my = (ay + by) / 2;
- const dx = bx - ax, dy = by - ay;
- const cpx = mx - dy * 0.25, cpy = my + dx * 0.25;
- const inv = 1 - t;
- return {
- x: inv*inv*ax + 2*inv*t*cpx + t*t*bx,
- y: inv*inv*ay + 2*inv*t*cpy + t*t*by,
- };
+// 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;
- // Resize canvas to its display size
canvas.width = canvas.clientWidth || window.innerWidth;
canvas.height = canvas.clientHeight || (window.innerHeight - 130);
+ const W = canvas.width, H = canvas.height;
+ const hub = _nmNodes[0];
+ const hx = hub?.x || W/2, hy = hub?.y || H/2;
function draw() {
if (!document.getElementById('netMapOverlay')?.classList.contains('nm-open')) return;
_nmRaf = requestAnimationFrame(draw);
_nmT += 0.016;
const ctx = canvas.getContext('2d');
- const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
- // Background hex-grid tint
- ctx.strokeStyle = 'rgba(0,180,255,0.04)';
- ctx.lineWidth = 0.5;
- const gs = 38;
- for (let x = 0; x < W; x += gs) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
- for (let y = 0; y < H; y += gs) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
+ // ── 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(); }
- // Draw edges
+ // ── 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);
+
+ // 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 steps = 60;
- ctx.beginPath();
- for (let i = 0; i <= steps; i++) {
- const p = _bezierPt(a.x, a.y, b.x, b.y, i / steps);
- i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
- }
+ if (!a||!b) return;
const alive = a.online && b.online;
- ctx.strokeStyle = alive ? 'rgba(0,180,220,0.14)' : 'rgba(100,20,40,0.12)';
- ctx.lineWidth = e.strength * 1.5;
- ctx.stroke();
+ 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();
});
- // Draw particles
+ // ── Particles ────────────────────────────────────────────────────
_nmParticles.forEach(p => {
- p.t += p.speed;
- if (p.t > 1) p.t -= 1;
- const e = _nmEdges[p.edge];
- if (!e) return;
+ 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);
-
- // Fade near endpoints
- const fade = Math.min(t * 6, (1 - t) * 6, 1);
+ 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') {
- // Cyan: data flowing to hub
- ctx.beginPath(); ctx.arc(pt.x, pt.y, p.r, 0, Math.PI * 2);
- ctx.fillStyle = `rgba(0,212,255,${(0.6 + Math.sin(_nmT*3+p.t*10)*0.3) * fade})`;
- ctx.shadowColor = 'rgba(0,212,255,0.6)'; ctx.shadowBlur = 6;
- ctx.fill(); ctx.shadowBlur = 0;
+ ctx.fillStyle = `rgba(0,212,255,${bright*fade})`;
+ ctx.shadowColor='rgba(0,212,255,0.7)';
} else {
- // Orange: commands from hub
- ctx.beginPath(); ctx.arc(pt.x, pt.y, p.r * 0.85, 0, Math.PI * 2);
- ctx.fillStyle = `rgba(255,140,0,${(0.55 + Math.sin(_nmT*4+p.t*8)*0.25) * fade})`;
- ctx.shadowColor = 'rgba(255,120,0,0.5)'; ctx.shadowBlur = 5;
- ctx.fill(); ctx.shadowBlur = 0;
+ 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;
});
- // Draw nodes
+ // ── Nodes ────────────────────────────────────────────────────────
_nmNodes.forEach((n, ni) => {
- const col = _nodeColor(n);
- const pulse = 0.55 + Math.sin(_nmT * 1.6 + n.pulse) * 0.3;
- const isHover = _nmHoverNode === ni;
- const baseR = n.r + (isHover ? 4 : 0);
-
- if (n.online) {
- // Outer glow ring
- const glowR = baseR + 10 + Math.sin(_nmT + n.pulse) * 4;
- const g = ctx.createRadialGradient(n.x, n.y, baseR * 0.5, n.x, n.y, glowR);
- g.addColorStop(0, `rgba(${col.r},${col.g},${col.b},${pulse * 0.3})`);
- g.addColorStop(1, `rgba(${col.r},${col.g},${col.b},0)`);
- ctx.beginPath(); ctx.arc(n.x, n.y, glowR, 0, Math.PI*2);
- ctx.fillStyle = g; ctx.fill();
-
- // Rotating orbit ring for hub and agents
- if (n.type === 'hub' || n.agent) {
- ctx.beginPath();
- ctx.arc(n.x, n.y, baseR + 6, _nmT * (n.type==='hub'?0.8:0.5), _nmT * (n.type==='hub'?0.8:0.5) + Math.PI * 1.4);
- ctx.strokeStyle = `rgba(${col.r},${col.g},${col.b},${0.3 + pulse*0.2})`;
- ctx.lineWidth = 1; ctx.stroke();
- }
+ 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;
}
- // Node fill
- const filled = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, baseR);
- const a = n.online ? pulse * 0.5 : 0.15;
- filled.addColorStop(0, `rgba(${col.r},${col.g},${col.b},${a})`);
- filled.addColorStop(1, `rgba(${col.r},${col.g},${col.b},${a*0.3})`);
- ctx.beginPath(); ctx.arc(n.x, n.y, baseR, 0, Math.PI*2);
- ctx.fillStyle = filled; ctx.fill();
+ 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);
- // Node border
- ctx.beginPath(); ctx.arc(n.x, n.y, baseR, 0, Math.PI*2);
- ctx.strokeStyle = `rgba(${col.r},${col.g},${col.b},${n.online ? 0.7+pulse*0.3 : 0.25})`;
- ctx.lineWidth = n.type === 'hub' ? 2 : 1.2; ctx.stroke();
+ // 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)) {
+ ctx.beginPath();
+ const arcStart = _nmT*(isHub?0.7:0.45);
+ ctx.arc(n.x,n.y,baseR+5,arcStart,arcStart+Math.PI*1.3);
+ ctx.strokeStyle=`rgba(${rgb},${0.28+pulse*0.18})`; ctx.lineWidth=isHub?1.5:1; 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);
+ 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();
// Hub cross-hairs
- if (n.type === 'hub') {
- ctx.strokeStyle = `rgba(${col.r},${col.g},${col.b},0.25)`;
- ctx.lineWidth = 0.5;
- [[n.x-40,n.y,n.x-baseR-4,n.y],[n.x+baseR+4,n.y,n.x+40,n.y],
- [n.x,n.y-40,n.x,n.y-baseR-4],[n.x,n.y+baseR+4,n.x,n.y+40]].forEach(([x1,y1,x2,y2])=>{
- ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
- });
+ 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();});
}
- // Label
- ctx.font = `${n.type==='hub'?'700 ':''} ${n.type==='hub'?9:7.5}px Share Tech Mono,monospace`;
- ctx.textAlign = 'center';
- ctx.fillStyle = n.online ? `rgba(${col.r},${col.g},${col.b},0.9)` : 'rgba(200,80,80,0.6)';
- ctx.fillText(n.label, n.x, n.y + baseR + 12);
- if (n.sub && (n.type==='hub'||isHover)) {
- ctx.font = '6.5px Share Tech Mono,monospace';
- ctx.fillStyle = 'rgba(150,200,220,0.55)';
- ctx.fillText(n.sub, n.x, n.y + baseR + 20);
+ // 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);
+
+ // 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));
}
- ctx.textAlign = 'left';
+ ctx.textAlign='left';
});
}
draw();
- // Mouse hover for node info
+ // Hover detection
canvas.onmousemove = function(e) {
- const rect = canvas.getBoundingClientRect();
- const mx = e.clientX - rect.left, my = e.clientY - rect.top;
- let found = -1;
- _nmNodes.forEach((n, i) => {
- if (Math.hypot(n.x - mx, n.y - my) < n.r + 8) found = i;
- });
- _nmHoverNode = found >= 0 ? found : null;
- const info = document.getElementById('nmNodeInfo');
- if (!info) return;
- if (found >= 0) {
- const n = _nmNodes[found];
- const col = _nodeColor(n);
- document.getElementById('ni-name').textContent = n.label;
- document.getElementById('ni-name').style.color = `rgb(${col.r},${col.g},${col.b})`;
- 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 + 14) + 'px';
- info.style.top = (my - 10) + 'px';
- } else {
- info.style.display = 'none';
- }
- };
- canvas.onmouseleave = () => {
- _nmHoverNode = null;
- const info = document.getElementById('nmNodeInfo');
- if (info) info.style.display = 'none';
+ const r = canvas.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top;
+ 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;
+ if(found>=0){
+ const n=_nmNodes[found], rgb=n.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';
+ } 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 ──────────────────────────────────────────────────────────