const nodesTable = document.getElementById('nodesTable'); const messagesList = document.getElementById('messagesList'); const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); const nodeCountBadge = document.getElementById('nodeCountBadge'); let nodes = {}; let channels = {}; let ws; function connectWebSocket() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${proto}//${location.host}/ws`); ws.onopen = () => { statusDot.classList.add('connected'); statusText.textContent = 'Verbunden'; }; ws.onclose = () => { statusDot.classList.remove('connected'); statusText.textContent = 'Getrennt - Reconnect...'; setTimeout(connectWebSocket, 3000); }; ws.onerror = () => { ws.close(); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); switch (msg.type) { case 'initial': msg.data.forEach(node => { nodes[node.node_id] = node; }); renderNodes(); break; case 'node_update': nodes[msg.data.node_id] = msg.data; renderNodes(); break; case 'initial_messages': msg.data.reverse().forEach(m => addMessage(m)); break; case 'channels': channels = msg.data; break; case 'new_message': addMessage(msg.data); break; case 'stats_update': updateStats(msg.data); break; } }; } function renderNodes() { const sorted = Object.values(nodes).sort((a, b) => (b.last_seen || 0) - (a.last_seen || 0)); nodeCountBadge.textContent = sorted.length; nodesTable.innerHTML = sorted.map(node => { let name = node.node_id; if (node.long_name && node.short_name) { name = `${node.long_name} (${node.short_name})`; } else if (node.long_name) { name = node.long_name; } else if (node.short_name) { name = node.short_name; } const hw = node.hw_model || '-'; const snr = node.snr != null ? `${node.snr.toFixed(1)} dB` : '-'; const battery = renderBattery(node.battery); const hops = node.hop_count != null ? node.hop_count : '-'; const lastSeen = node.last_seen ? timeAgo(node.last_seen) : '-'; const onlineClass = isOnline(node.last_seen) ? 'text-success' : ''; return ` ${escapeHtml(name)} ${escapeHtml(hw)} ${snr} ${battery} ${hops} ${lastSeen} `; }).join(''); } function renderBattery(level) { if (level == null) return '-'; let colorClass = 'bg-success'; if (level < 20) colorClass = 'bg-danger'; else if (level < 50) colorClass = 'bg-warning'; return ` ${level}% `; } function addMessage(msg) { const item = document.createElement('div'); item.className = 'list-group-item list-group-item-action py-2 px-3'; const time = msg.timestamp ? new Date(msg.timestamp * 1000).toLocaleTimeString('de-DE') : ''; const fromNode = nodes[msg.from_node]; const from = (fromNode && fromNode.long_name) ? fromNode.long_name : (msg.from_node || '?'); const chIdx = msg.channel != null ? msg.channel : '?'; const chName = channels[chIdx] || `Ch ${chIdx}`; item.innerHTML = `
${escapeHtml(from)} ${escapeHtml(chName)}${time}
${escapeHtml(msg.payload || '')}
`; messagesList.prepend(item); while (messagesList.children.length > 100) { messagesList.removeChild(messagesList.lastChild); } } function updateStats(stats) { document.getElementById('statNodes').textContent = stats.total_nodes || 0; document.getElementById('statPositions').textContent = stats.nodes_with_position || 0; document.getElementById('statMessages').textContent = stats.total_messages || 0; document.getElementById('statTextMessages').textContent = stats.text_messages || 0; } function isOnline(lastSeen) { if (!lastSeen) return false; return (Date.now() / 1000 - lastSeen) < 900; // < 15 min } function timeAgo(timestamp) { const seconds = Math.floor(Date.now() / 1000 - timestamp); if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; return `${Math.floor(seconds / 86400)}d`; } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // Theme toggle const themeToggle = document.getElementById('themeToggle'); const themeIcon = document.getElementById('themeIcon'); function applyTheme(theme) { document.documentElement.setAttribute('data-bs-theme', theme); themeIcon.className = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill'; localStorage.setItem('theme', theme); } // Load saved theme applyTheme(localStorage.getItem('theme') || 'dark'); themeToggle.addEventListener('click', () => { const current = document.documentElement.getAttribute('data-bs-theme'); applyTheme(current === 'dark' ? 'light' : 'dark'); }); connectWebSocket();