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 currentUser = null; let nodes = {}; let channels = {}; let myNodeId = null; let ws; // Auth check fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => { currentUser = u; updateNavbar(); updateSidebar(); updateVisibility(); }); function updateNavbar() { if (currentUser) { document.getElementById('userName').textContent = currentUser.name; document.getElementById('userMenu').classList.remove('d-none'); document.getElementById('loginBtn').classList.add('d-none'); } else { document.getElementById('userMenu').classList.add('d-none'); document.getElementById('loginBtn').classList.remove('d-none'); } } function updateSidebar() { const isAdmin = currentUser && currentUser.role === 'admin'; document.querySelectorAll('.sidebar-admin').forEach(el => { el.style.display = isAdmin ? '' : 'none'; }); } function updateVisibility() { const loggedIn = !!currentUser; const sendCard = document.getElementById('sendCard'); const messagesCard = document.getElementById('messagesCard'); if (loggedIn) { sendCard.classList.remove('d-none'); messagesCard.classList.remove('d-none'); } } 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; populateChannelDropdown(); break; case 'my_node_id': myNodeId = 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 iconClass, colorClass; if (level > 75) { iconClass = 'bi-battery-full'; colorClass = 'text-success'; } else if (level > 50) { iconClass = 'bi-battery-half'; colorClass = 'text-success'; } else if (level > 20) { iconClass = 'bi-battery-half'; colorClass = 'text-warning'; } else { iconClass = 'bi-battery'; colorClass = 'text-danger'; } return `${level}%`; } function addMessage(msg) { const item = document.createElement('div'); const isSent = myNodeId && msg.from_node === myNodeId; item.className = 'msg-item' + (isSent ? ' msg-sent' : ''); const time = msg.timestamp ? new Date(msg.timestamp * 1000).toLocaleTimeString('de-DE') : ''; const fromNode = nodes[msg.from_node]; const from = isSent ? 'Bot' : ((fromNode && fromNode.long_name) ? fromNode.long_name : (msg.from_node || '?')); const chIdx = msg.channel != null ? msg.channel : '?'; const chName = channels[chIdx] || `Ch ${chIdx}`; const bubbleClass = isSent ? 'msg-bubble msg-bubble-sent' : 'msg-bubble'; const icon = isSent ? 'bi-send-fill' : 'bi-person-fill'; 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) { if (stats.version) { document.getElementById('versionLabel').textContent = `v${stats.version}`; } document.getElementById('statNodes').textContent = stats.total_nodes || 0; document.getElementById('statNodes24h').textContent = stats.nodes_24h || 0; document.getElementById('statCommands').textContent = stats.total_commands || 0; const chBreakdown = document.getElementById('channelBreakdown'); const chCounts = stats.channel_breakdown || {}; const sortedChannels = Object.entries(channels).sort((a, b) => parseInt(a[0]) - parseInt(b[0])); chBreakdown.innerHTML = sortedChannels.map(([chIdx, chName]) => { const count = chCounts[chIdx] || 0; return `${escapeHtml(chName)} ${count}`; }).join(''); } 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; } // Send message function populateChannelDropdown() { const sel = document.getElementById('sendChannel'); sel.innerHTML = ''; for (const [idx, name] of Object.entries(channels)) { const opt = document.createElement('option'); opt.value = idx; opt.textContent = name; sel.appendChild(opt); } } async function sendMessage() { const textEl = document.getElementById('sendText'); const text = textEl.value.trim(); if (!text) return; const channel = parseInt(document.getElementById('sendChannel').value) || 0; const btn = document.getElementById('btnSend'); btn.disabled = true; try { await fetch('/api/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, channel }) }); textEl.value = ''; } catch (e) { console.error('Send failed:', e); } finally { btn.disabled = false; textEl.focus(); } } document.getElementById('btnSend').addEventListener('click', sendMessage); document.getElementById('sendText').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); sendMessage(); } }); // 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'); }); // Sidebar toggle (mobile) const sidebarToggle = document.getElementById('sidebarToggle'); const sidebar = document.getElementById('sidebar'); const sidebarBackdrop = document.getElementById('sidebarBackdrop'); if (sidebarToggle) { sidebarToggle.addEventListener('click', () => sidebar.classList.toggle('open')); sidebarBackdrop.addEventListener('click', () => sidebar.classList.remove('open')); } // Node Detail Modal let nodeModalMap = null; let nodeModalMarker = null; const nodeModalEl = document.getElementById('nodeModal'); const nodeModal = new bootstrap.Modal(nodeModalEl); nodesTable.addEventListener('click', (e) => { const row = e.target.closest('tr[data-node-id]'); if (!row) return; showNodeModal(row.dataset.nodeId); }); function showNodeModal(nodeId) { const node = nodes[nodeId]; if (!node) return; // Header const name = node.long_name || node.short_name || node.node_id; document.getElementById('modalNodeName').textContent = name; const dot = document.getElementById('modalStatusDot'); if (isOnline(node.last_seen)) { dot.classList.add('connected'); } else { dot.classList.remove('connected'); } // Details table const fmt = (v) => v != null && v !== '' ? escapeHtml(String(v)) : '-'; const fmtTime = (ts) => ts ? new Date(ts * 1000).toLocaleString('de-DE') : '-'; const rows = [ ['Node-ID', fmt(node.node_id)], ['Long Name', fmt(node.long_name)], ['Short Name', fmt(node.short_name)], ['Hardware', fmt(node.hw_model)], ['SNR', node.snr != null ? `${node.snr.toFixed(1)} dB` : '-'], ['RSSI', node.rssi != null ? `${node.rssi} dBm` : '-'], ['Batterie', node.battery != null ? `${node.battery}%` : '-'], ['Spannung', node.voltage != null ? `${node.voltage.toFixed(2)} V` : '-'], ['Hops', fmt(node.hop_count)], ['Via MQTT', node.via_mqtt ? 'Ja' : 'Nein'], ['Hoehe', node.alt != null ? `${node.alt} m` : '-'], ['Erste Verbindung', fmtTime(node.first_seen)], ['Letzte Verbindung', fmtTime(node.last_seen)], ]; document.getElementById('modalNodeDetails').innerHTML = rows.map(([label, val]) => `${label}${val}` ).join(''); // Map const mapContainer = document.getElementById('modalMapContainer'); const noPos = document.getElementById('modalNoPosition'); const hasPosition = node.lat != null && node.lon != null && (node.lat !== 0 || node.lon !== 0); if (hasPosition) { mapContainer.classList.remove('d-none'); noPos.classList.add('d-none'); } else { mapContainer.classList.add('d-none'); noPos.classList.remove('d-none'); } nodeModal.show(); if (hasPosition) { nodeModalEl.addEventListener('shown.bs.modal', function onShown() { nodeModalEl.removeEventListener('shown.bs.modal', onShown); if (!nodeModalMap) { nodeModalMap = L.map(mapContainer).setView([node.lat, node.lon], 14); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(nodeModalMap); nodeModalMarker = L.marker([node.lat, node.lon]).addTo(nodeModalMap); } else { nodeModalMap.setView([node.lat, node.lon], 14); nodeModalMarker.setLatLng([node.lat, node.lon]); } nodeModalMap.invalidateSize(); }); } } connectWebSocket();