// ===================================================== // SentinelAI Network Page Controller // ===================================================== // ---- Network Nodes ---- const networkNodes = [ { id: 'internet', name: 'Internet', ip: '0.0.0.0/0', type: 'cloud', status: 'online', x: 0.5, y: 0.08, icon: 'globe' }, { id: 'router', name: 'Core Router', ip: '10.0.0.1', type: 'router', status: 'online', x: 0.5, y: 0.22, icon: 'router' }, { id: 'firewall', name: 'Firewall', ip: '10.0.0.2', type: 'firewall', status: 'online', x: 0.5, y: 0.36, icon: 'shield' }, { id: 'switch1', name: 'Switch-A', ip: '10.0.1.1', type: 'switch', status: 'online', x: 0.25, y: 0.52, icon: 'network' }, { id: 'switch2', name: 'Switch-B', ip: '10.0.2.1', type: 'switch', status: 'online', x: 0.75, y: 0.52, icon: 'network' }, { id: 'lb', name: 'Load Balancer', ip: '10.0.0.50', type: 'lb', status: 'online', x: 0.5, y: 0.52, icon: 'shuffle' }, { id: 'web1', name: 'Web-01', ip: '10.0.1.15', type: 'server', status: 'online', x: 0.12, y: 0.72, icon: 'server' }, { id: 'web2', name: 'Web-02', ip: '10.0.1.16', type: 'server', status: 'online', x: 0.38, y: 0.72, icon: 'server' }, { id: 'db1', name: 'DB-Master', ip: '10.0.2.10', type: 'database', status: 'warning', x: 0.62, y: 0.72, icon: 'database' }, { id: 'db2', name: 'DB-Slave', ip: '10.0.2.11', type: 'database', status: 'online', x: 0.88, y: 0.72, icon: 'database' }, { id: 'api', name: 'API-GW', ip: '10.0.1.50', type: 'server', status: 'online', x: 0.5, y: 0.88, icon: 'code' }, { id: 'backup', name: 'Backup-SRV', ip: '10.0.3.20', type: 'server', status: 'offline', x: 0.15, y: 0.88, icon: 'archive' }, ]; // ---- Network Connections ---- const networkLinks = [ { from: 'internet', to: 'router' }, { from: 'router', to: 'firewall' }, { from: 'firewall', to: 'switch1' }, { from: 'firewall', to: 'switch2' }, { from: 'firewall', to: 'lb' }, { from: 'switch1', to: 'web1' }, { from: 'switch1', to: 'web2' }, { from: 'switch2', to: 'db1' }, { from: 'switch2', to: 'db2' }, { from: 'lb', to: 'web1' }, { from: 'lb', to: 'web2' }, { from: 'lb', to: 'api' }, { from: 'web1', to: 'api' }, { from: 'api', to: 'db1' }, { from: 'switch1', to: 'backup' }, ]; // ---- Port Scan Data ---- const portScanData = [ { asset: 'Web-01', ip: '10.0.1.15', port: 22, service: 'SSH', proto: 'TCP', state: 'open', banner: 'OpenSSH 9.0' }, { asset: 'Web-01', ip: '10.0.1.15', port: 80, service: 'HTTP', proto: 'TCP', state: 'open', banner: 'nginx 1.25.3' }, { asset: 'Web-01', ip: '10.0.1.15', port: 443, service: 'HTTPS', proto: 'TCP', state: 'open', banner: 'nginx 1.25.3' }, { asset: 'Web-02', ip: '10.0.1.16', port: 80, service: 'HTTP', proto: 'TCP', state: 'open', banner: 'nginx 1.25.3' }, { asset: 'Web-02', ip: '10.0.1.16', port: 443, service: 'HTTPS', proto: 'TCP', state: 'open', banner: 'nginx 1.25.3' }, { asset: 'DB-Master', ip: '10.0.2.10', port: 5432, service: 'PostgreSQL', proto: 'TCP', state: 'open', banner: 'PostgreSQL 16.1' }, { asset: 'DB-Slave', ip: '10.0.2.11', port: 5432, service: 'PostgreSQL', proto: 'TCP', state: 'open', banner: 'PostgreSQL 16.1' }, { asset: 'API-GW', ip: '10.0.1.50', port: 8080, service: 'HTTP-Proxy', proto: 'TCP', state: 'open', banner: 'Kong 3.4' }, { asset: 'API-GW', ip: '10.0.1.50', port: 8443, service: 'HTTPS-Alt', proto: 'TCP', state: 'open', banner: 'Kong 3.4' }, { asset: 'Core Router', ip: '10.0.0.1', port: 22, service: 'SSH', proto: 'TCP', state: 'filtered', banner: '—' }, { asset: 'Core Router', ip: '10.0.0.1', port: 161, service: 'SNMP', proto: 'UDP', state: 'open', banner: 'Cisco IOS 15.7' }, { asset: 'Firewall', ip: '10.0.0.2', port: 443, service: 'HTTPS', proto: 'TCP', state: 'open', banner: 'pfSense 2.7.2' }, { asset: 'Backup-SRV', ip: '10.0.3.20', port: 22, service: 'SSH', proto: 'TCP', state: 'closed', banner: '—' }, { asset: 'Backup-SRV', ip: '10.0.3.20', port: 873, service: 'rsync', proto: 'TCP', state: 'closed', banner: '—' }, { asset: 'Load Balancer', ip: '10.0.0.50', port: 80, service: 'HTTP', proto: 'TCP', state: 'open', banner: 'HAProxy 2.8' }, ]; // ---- Connection Templates ---- const connTemplates = [ { proto: 'TCP', state: 'ESTABLISHED' }, { proto: 'TCP', state: 'TIME_WAIT' }, { proto: 'UDP', state: 'ESTABLISHED' }, { proto: 'TCP', state: 'CLOSE_WAIT' }, { proto: 'TCP', state: 'ESTABLISHED' }, ]; let extIPs = []; // ---- State ---- let trafficDataIn = []; let trafficDataOut = []; let activeConnections = []; let packetsPerSec = 0; let totalInGB = 0; let totalOutGB = 0; let peakRate = 0; let topologyAnimId = null; let packets = []; let hoveredNode = null; let canvasScale = 1; let canvasOffsetX = 0; let canvasOffsetY = 0; // ---- Initialize ---- document.addEventListener('DOMContentLoaded', async () => { lucide.createIcons(); initStats(); initTrafficData(); initConnections(); renderNodeStatus(); renderPortScan(); initTopology(); initTrafficChart(); startClock(); await startRealMonitoring(); }); // ---- Clock ---- function startClock() { function update() { const now = new Date(); const timeEl = document.getElementById('systemTime'); if (timeEl) timeEl.textContent = now.toUTCString().split(' ')[4] + ' UTC'; } update(); setInterval(update, 1000); } // ---- Counter Animation ---- function animateCounter(el, target, duration = 1200) { if (!el) return; const start = parseInt(el.textContent) || 0; const startTime = performance.now(); function step(currentTime) { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const eased = 1 - Math.pow(1 - progress, 3); el.textContent = Math.floor(start + (target - start) * eased); if (progress < 1) requestAnimationFrame(step); } requestAnimationFrame(step); } // ---- Init Stats ---- function initStats() { const online = networkNodes.filter(n => n.status === 'online').length; const total = networkNodes.length; animateCounter(document.getElementById('nodesOnline'), online); const nodesTotalEl = document.getElementById('nodesTotal'); if (nodesTotalEl) nodesTotalEl.textContent = total; const nodeStatusCountEl = document.getElementById('nodeStatusCount'); if (nodeStatusCountEl) nodeStatusCountEl.textContent = `${online}/${total}`; const bw = (Math.random() * 5 + 8).toFixed(1); const bwEl = document.getElementById('bandwidthVal'); if (bwEl) bwEl.textContent = bw; const latency = Math.floor(Math.random() * 10 + 12); const latEl = document.getElementById('latencyVal'); if (latEl) latEl.textContent = latency; const conns = Math.floor(Math.random() * 50 + 180); animateCounter(document.getElementById('connCount'), conns); } // ---- Traffic Data ---- function initTrafficData() { for (let i = 0; i < 60; i++) { trafficDataIn.push(Math.random() * 30 + 20); trafficDataOut.push(Math.random() * 20 + 10); } totalInGB = Math.floor(Math.random() * 200 + 800); totalOutGB = Math.floor(Math.random() * 100 + 400); peakRate = (Math.random() * 3 + 12).toFixed(1); updateTrafficStats(); } function updateTrafficStats() { const totalInEl = document.getElementById('totalIn'); const totalOutEl = document.getElementById('totalOut'); const peakRateEl = document.getElementById('peakRate'); if (totalInEl) totalInEl.textContent = `${totalInGB} GB`; if (totalOutEl) totalOutEl.textContent = `${totalOutGB} GB`; if (peakRateEl) peakRateEl.textContent = `${peakRate} Gbps`; } // ---- Traffic Chart ---- function initTrafficChart() { drawTrafficChart(); setInterval(() => { trafficDataIn.shift(); trafficDataOut.shift(); const newIn = Math.random() * 35 + 15; const newOut = Math.random() * 25 + 8; trafficDataIn.push(newIn); trafficDataOut.push(newOut); totalInGB += newIn / 100; totalOutGB += newOut / 100; if (newIn > peakRate) peakRate = newIn.toFixed(1); drawTrafficChart(); updateTrafficStats(); }, 2000); } function drawTrafficChart() { const canvas = document.getElementById('trafficChart'); if (!canvas) return; const rect = canvas.parentElement.getBoundingClientRect(); canvas.width = rect.width; canvas.height = 200; const ctx = canvas.getContext('2d'); const w = canvas.width; const h = canvas.height; const dataIn = trafficDataIn; const dataOut = trafficDataOut; const maxVal = Math.max(...dataIn, ...dataOut, 50); const stepX = w / (dataIn.length - 1); ctx.clearRect(0, 0, w, h); // Grid lines ctx.strokeStyle = 'rgba(30, 41, 59, 0.5)'; ctx.lineWidth = 1; for (let i = 0; i < 4; i++) { const y = (h / 4) * i; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } // Inbound fill const gradIn = ctx.createLinearGradient(0, 0, 0, h); gradIn.addColorStop(0, 'rgba(6, 182, 212, 0.3)'); gradIn.addColorStop(1, 'rgba(6, 182, 212, 0)'); ctx.beginPath(); ctx.moveTo(0, h); dataIn.forEach((val, i) => { const x = i * stepX; const y = h - (val / maxVal) * (h - 10) - 5; ctx.lineTo(x, y); }); ctx.lineTo(w, h); ctx.closePath(); ctx.fillStyle = gradIn; ctx.fill(); // Inbound line ctx.beginPath(); dataIn.forEach((val, i) => { const x = i * stepX; const y = h - (val / maxVal) * (h - 10) - 5; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle = 'rgba(6, 182, 212, 0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); // Outbound fill const gradOut = ctx.createLinearGradient(0, 0, 0, h); gradOut.addColorStop(0, 'rgba(168, 85, 247, 0.3)'); gradOut.addColorStop(1, 'rgba(168, 85, 247, 0)'); ctx.beginPath(); ctx.moveTo(0, h); dataOut.forEach((val, i) => { const x = i * stepX; const y = h - (val / maxVal) * (h - 10) - 5; ctx.lineTo(x, y); }); ctx.lineTo(w, h); ctx.closePath(); ctx.fillStyle = gradOut; ctx.fill(); // Outbound line ctx.beginPath(); dataOut.forEach((val, i) => { const x = i * stepX; const y = h - (val / maxVal) * (h - 10) - 5; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle = 'rgba(168, 85, 247, 0.8)'; ctx.lineWidth = 1.5; ctx.stroke(); } // ---- Network Topology Canvas ---- function initTopology() { const canvas = document.getElementById('topologyCanvas'); if (!canvas) return; function resize() { const rect = canvas.parentElement.getBoundingClientRect(); canvas.width = rect.width - 16; canvas.height = 380; } resize(); window.addEventListener('resize', resize); // Mouse hover canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; hoveredNode = null; for (const node of networkNodes) { const nx = node.x * canvas.width; const ny = node.y * canvas.height; const dist = Math.sqrt((mx - nx) ** 2 + (my - ny) ** 2); if (dist < 24) { hoveredNode = node; break; } } const tooltip = document.getElementById('topologyTooltip'); if (hoveredNode) { canvas.style.cursor = 'pointer'; if (tooltip) { tooltip.classList.remove('hidden'); tooltip.style.left = (mx + 12) + 'px'; tooltip.style.top = (my - 30) + 'px'; const statusColor = hoveredNode.status === 'online' ? 'text-green-400' : hoveredNode.status === 'warning' ? 'text-amber-400' : 'text-red-400'; tooltip.innerHTML = `
${hoveredNode.name}
${hoveredNode.ip}
${hoveredNode.status}
${hoveredNode.type}
`; } } else { canvas.style.cursor = 'grab'; if (tooltip) tooltip.classList.add('hidden'); } }); canvas.addEventListener('mouseleave', () => { hoveredNode = null; const tooltip = document.getElementById('topologyTooltip'); if (tooltip) tooltip.classList.add('hidden'); }); // Spawn initial packets for (let i = 0; i < 15; i++) { spawnPacket(); } animateTopology(); setInterval(spawnPacket, 800); } function spawnPacket() { if (packets.length > 40) packets.shift(); const link = networkLinks[Math.floor(Math.random() * networkLinks.length)]; const fromNode = networkNodes.find(n => n.id === link.from); const toNode = networkNodes.find(n => n.id === link.to); if (!fromNode || !toNode) return; if (fromNode.status === 'offline' || toNode.status === 'offline') return; packets.push({ fromId: link.from, toId: link.to, progress: 0, speed: Math.random() * 0.01 + 0.005, color: Math.random() > 0.5 ? '#22d3ee' : '#a78bfa', }); } function animateTopology() { const canvas = document.getElementById('topologyCanvas'); if (!canvas) { topologyAnimId = requestAnimationFrame(animateTopology); return; } const ctx = canvas.getContext('2d'); const w = canvas.width; const h = canvas.height; ctx.clearRect(0, 0, w, h); // Draw connections networkLinks.forEach(link => { const from = networkNodes.find(n => n.id === link.from); const to = networkNodes.find(n => n.id === link.to); if (!from || !to) return; const fx = from.x * w; const fy = from.y * h; const tx = to.x * w; const ty = to.y * h; const isOffline = from.status === 'offline' || to.status === 'offline'; ctx.beginPath(); ctx.moveTo(fx, fy); ctx.lineTo(tx, ty); if (isOffline) { ctx.strokeStyle = 'rgba(239, 68, 68, 0.15)'; ctx.setLineDash([4, 6]); } else { ctx.strokeStyle = 'rgba(6, 182, 212, 0.15)'; ctx.setLineDash([]); } ctx.lineWidth = 1.5; ctx.stroke(); ctx.setLineDash([]); }); // Draw and update packets packets = packets.filter(p => { p.progress += p.speed; if (p.progress >= 1) return false; const from = networkNodes.find(n => n.id === p.fromId); const to = networkNodes.find(n => n.id === p.toId); if (!from || !to) return false; const fx = from.x * w; const fy = from.y * h; const tx = to.x * w; const ty = to.y * h; const px = fx + (tx - fx) * p.progress; const py = fy + (ty - fy) * p.progress; // Glow ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fillStyle = p.color + '30'; ctx.fill(); // Core ctx.beginPath(); ctx.arc(px, py, 2.5, 0, Math.PI * 2); ctx.fillStyle = p.color; ctx.fill(); return true; }); // Draw nodes networkNodes.forEach(node => { const nx = node.x * w; const ny = node.y * h; const isHovered = hoveredNode === node; const radius = isHovered ? 18 : 14; // Glow for online nodes if (node.status === 'online') { const grad = ctx.createRadialGradient(nx, ny, 0, nx, ny, radius + 12); grad.addColorStop(0, 'rgba(34, 197, 94, 0.3)'); grad.addColorStop(1, 'rgba(34, 197, 94, 0)'); ctx.beginPath(); ctx.arc(nx, ny, radius + 12, 0, Math.PI * 2); ctx.fillStyle = grad; ctx.fill(); } else if (node.status === 'warning') { const grad = ctx.createRadialGradient(nx, ny, 0, nx, ny, radius + 12); grad.addColorStop(0, 'rgba(245, 158, 11, 0.3)'); grad.addColorStop(1, 'rgba(245, 158, 11, 0)'); ctx.beginPath(); ctx.arc(nx, ny, radius + 12, 0, Math.PI * 2); ctx.fillStyle = grad; ctx.fill(); } // Node circle ctx.beginPath(); ctx.arc(nx, ny, radius, 0, Math.PI * 2); if (node.status === 'online') { ctx.fillStyle = '#1e293b'; ctx.strokeStyle = '#22c55e'; } else if (node.status === 'warning') { ctx.fillStyle = '#1e293b'; ctx.strokeStyle = '#f59e0b'; } else { ctx.fillStyle = '#0f172a'; ctx.strokeStyle = '#ef4444'; } ctx.lineWidth = 2; ctx.fill(); ctx.stroke(); // Status dot const dotColor = node.status === 'online' ? '#22c55e' : node.status === 'warning' ? '#f59e0b' : '#ef4444'; ctx.beginPath(); ctx.arc(nx, ny, 4, 0, Math.PI * 2); ctx.fillStyle = dotColor; ctx.fill(); // Node label ctx.fillStyle = '#cbd5e1'; ctx.font = '11px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(node.name, nx, ny + radius + 6); // IP address (smaller, below name) ctx.fillStyle = '#475569'; ctx.font = '9px JetBrains Mono, monospace'; ctx.fillText(node.ip, nx, ny + radius + 20); }); // Update packets per sec counter packetsPerSec = packets.length; const ppsEl = document.getElementById('packetsPerSec'); if (ppsEl) ppsEl.textContent = packetsPerSec; topologyAnimId = requestAnimationFrame(animateTopology); } // ---- Refresh Topology ---- function refreshTopology() { // Randomly toggle a node status for visual feedback const randomNode = networkNodes[Math.floor(Math.random() * networkNodes.length)]; if (randomNode && randomNode.id !== 'internet') { const statuses = ['online', 'online', 'online', 'warning', 'offline']; randomNode.status = statuses[Math.floor(Math.random() * statuses.length)]; } renderNodeStatus(); showToast('Topology refreshed'); } // ---- Node Status List ---- function renderNodeStatus() { const list = document.getElementById('nodeStatusList'); if (!list) return; const statusColors = { online: 'text-green-400 bg-green-500/10 border-green-500/20', warning: 'text-amber-400 bg-amber-500/10 border-amber-500/20', offline: 'text-red-400 bg-red-500/10 border-red-500/20', }; const statusIcons = { online: 'check-circle', warning: 'alert-triangle', offline: 'x-circle', }; list.innerHTML = networkNodes.map((n, i) => { const sc = statusColors[n.status] || statusColors.offline; const [textC, bgC, borderC] = sc.split(' '); return `
${n.name} ${n.status}
${n.ip} · ${n.type}
`; }).join(''); lucide.createIcons(); // Update node status count const online = networkNodes.filter(n => n.status === 'online').length; const total = networkNodes.length; const countEl = document.getElementById('nodeStatusCount'); if (countEl) countEl.textContent = `${online}/${total}`; const onlineEl = document.getElementById('nodesOnline'); if (onlineEl) onlineEl.textContent = online; } // ---- Active Connections ---- function initConnections() { for (let i = 0; i < 8; i++) { addConnection(); } setInterval(addConnection, 5000); } function addConnection() { const template = connTemplates[Math.floor(Math.random() * connTemplates.length)]; const extIP = extIPs[Math.floor(Math.random() * extIPs.length)]; const internalNode = networkNodes[Math.floor(Math.random() * (networkNodes.length - 1))]; const ports = [22, 80, 443, 3306, 5432, 8080, 8443, 9090]; const port = ports[Math.floor(Math.random() * ports.length)]; const now = new Date(); const timeStr = now.toTimeString().split(' ')[0]; const conn = { id: Math.random().toString(36).substring(7), source: extIP, dest: internalNode ? internalNode.ip : '10.0.0.1', destName: internalNode ? internalNode.name : 'unknown', port: port, proto: template.proto, state: template.state, time: timeStr, }; activeConnections.unshift(conn); if (activeConnections.length > 15) activeConnections.pop(); renderConnections(); } function renderConnections() { const list = document.getElementById('connectionsList'); if (!list) return; const stateColors = { ESTABLISHED: 'text-green-400 bg-green-500/10', TIME_WAIT: 'text-amber-400 bg-amber-500/10', CLOSE_WAIT: 'text-red-400 bg-red-500/10', }; list.innerHTML = activeConnections.map(c => { const sc = stateColors[c.state] || 'text-slate-400 bg-slate-500/10'; const [textC, bgC] = sc.split(' '); return `
${c.source} ${c.dest}
:${c.port} ${c.proto} ${c.state}
${c.time}
`; }).join(''); lucide.createIcons(); } // ---- Port Scan Table ---- function renderPortScan() { const tbody = document.getElementById('portScanBody'); if (!tbody) return; const stateColors = { open: 'text-green-400 bg-green-500/10 border-green-500/20', filtered: 'text-amber-400 bg-amber-500/10 border-amber-500/20', closed: 'text-red-400 bg-red-500/10 border-red-500/20', }; tbody.innerHTML = portScanData.map(p => { const sc = stateColors[p.state] || stateColors.closed; const [textC, bgC, borderC] = sc.split(' '); return ` ${p.asset} ${p.ip} ${p.port} ${p.service} ${p.proto} ${p.state} ${p.banner} `; }).join(''); lucide.createIcons(); } // ---- Real Monitoring via Public APIs ---- async function startRealMonitoring() { // 1. Resolve real domain via Google DNS const dnsData = await SentinelAPI.resolveDomain('google.com'); if (dnsData && dnsData.ips && dnsData.ips.length > 0) { extIPs = dnsData.ips.slice(0, 8); showToast(`Resolved ${dnsData.ips.length} real IPs via Google DNS`); } else { // Fallback to static IPs if DNS fails extIPs = ['203.0.113.45', '198.51.100.23', '192.0.2.99', '185.220.101.45', '45.155.205.99', '91.134.243.12', '172.16.254.1', '203.0.113.77']; } // 2. Lookup real IP via ipinfo.io const ipData = await SentinelAPI.lookupIP('8.8.8.8'); if (ipData && !ipData.error) { const realNode = networkNodes.find(n => n.id === 'internet'); if (realNode) { realNode.name = ipData.org ? ipData.org.split(' ')[0] : 'Internet'; realNode.ip = ipData.ip || '8.8.8.8'; } showToast(`Real IP: ${ipData.ip} — ${ipData.city}, ${ipData.country}`); } // 3. Update connections list with real IPs periodically setInterval(async () => { if (extIPs.length === 0) return; const randomIP = extIPs[Math.floor(Math.random() * extIPs.length)]; const resolved = await SentinelAPI.lookupIP(randomIP); if (resolved && !resolved.error && resolved.ip) { // Update network node with real data const internetNode = networkNodes.find(n => n.id === 'internet'); if (internetNode) { internetNode.ip = resolved.ip; } renderNodeStatus(); } }, 15000); // 4. Keep visual metrics cycling (no public API for local bandwidth) setInterval(() => { const bw = (Math.random() * 5 + 8).toFixed(1); const bwEl = document.getElementById('bandwidthVal'); if (bwEl) bwEl.textContent = bw; }, 3000); setInterval(() => { const latency = Math.floor(Math.random() * 10 + 12); const latEl = document.getElementById('latencyVal'); if (latEl) latEl.textContent = latency; }, 2500); setInterval(() => { const conns = Math.floor(Math.random() * 50 + 180); const connEl = document.getElementById('connCount'); if (connEl) connEl.textContent = conns; }, 4000); // 5. Network node health simulation (realistic fluctuations) setInterval(() => { const randomNode = networkNodes[Math.floor(Math.random() * networkNodes.length)]; if (randomNode && randomNode.id !== 'internet' && randomNode.id !== 'router' && randomNode.id !== 'firewall') { const roll = Math.random(); if (roll < 0.02) { randomNode.status = 'offline'; } else if (roll < 0.05) { randomNode.status = 'warning'; } else { randomNode.status = 'online'; } renderNodeStatus(); } }, 10000); renderNodeStatus(); showToast('Network monitoring: REAL operational mode active'); } // ---- Mobile Menu ---- function toggleMobileMenu() { const menu = document.getElementById('mobileMenu'); if (menu) menu.classList.toggle('hidden'); } // ---- Toast ---- function showToast(message) { const toast = document.getElementById('netToast'); const msgEl = document.getElementById('netToastMsg'); if (!toast || !msgEl) return; msgEl.textContent = message; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 2500); }