const nodesTable = document.getElementById('nodesTable'); 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; let nodeSearch = ''; let nodeOnlineFilter = false; let nodeSortKey = 'last_seen'; let nodeSortDir = -1; let onlineThreshold = 900; let chartChannel = null; let chartHops = null; let chartHardware = null; let chartPacketTypes = null; let lastStats = null; initPage({ onAuth: (user) => { currentUser = user; updateVisibility(); } }); function updateVisibility() { if (currentUser) { document.getElementById('sendCard').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 'channels': channels = msg.data; populateChannelDropdown(); if (lastStats) updateChannelChart(lastStats); break; case 'my_node_id': myNodeId = msg.data; break; case 'stats_update': updateStats(msg.data); break; case 'bot_status': updateBotStatus(msg.data); break; } }; } function renderNodes() { let filtered = Object.values(nodes); if (nodeSearch) { const q = nodeSearch.toLowerCase(); filtered = filtered.filter(n => { const nm = (n.long_name || n.short_name || n.node_id || '').toLowerCase(); return nm.includes(q) || (n.hw_model || '').toLowerCase().includes(q) || (n.node_id || '').toLowerCase().includes(q); }); } if (nodeOnlineFilter) filtered = filtered.filter(n => isOnline(n.last_seen)); filtered.sort((a, b) => { let av, bv; if (nodeSortKey === 'name') { av = (a.long_name || a.short_name || a.node_id || '').toLowerCase(); bv = (b.long_name || b.short_name || b.node_id || '').toLowerCase(); } else { av = a[nodeSortKey] ?? (nodeSortDir < 0 ? -Infinity : Infinity); bv = b[nodeSortKey] ?? (nodeSortDir < 0 ? -Infinity : Infinity); } return av < bv ? -nodeSortDir : av > bv ? nodeSortDir : 0; }); nodeCountBadge.textContent = filtered.length; nodesTable.innerHTML = filtered.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 rssi = node.rssi != null ? `${node.rssi}` : '-'; 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' : ''; const hasPos = node.lat != null && node.lon != null; const posIcon = hasPos ? '' : ''; return ` ${escapeHtml(name)} ${escapeHtml(hw)} ${snr} ${rssi} ${battery} ${hops} ${posIcon} ${lastSeen} `; }).join(''); updateNodeCharts(); } 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 updateBotStatus(status) { const dot = document.getElementById('meshDot'); const text = document.getElementById('meshText'); if (!dot || !text) return; if (status.connected) { dot.classList.add('connected'); text.textContent = status.uptime || 'Mesh'; } else { dot.classList.remove('connected'); text.textContent = 'Getrennt'; } } function updateStats(stats) { lastStats = stats; if (stats.online_threshold != null) onlineThreshold = stats.online_threshold; 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; if (stats.uptime) document.getElementById('statUptime').textContent = stats.uptime; if (stats.bot_connected !== undefined) { updateBotStatus({ connected: stats.bot_connected, uptime: stats.uptime }); } updateChannelChart(stats); updatePacketTypeChart(stats); } function isOnline(lastSeen) { if (!lastSeen) return false; return (Date.now() / 1000 - lastSeen) < onlineThreshold; } 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`; } // ── Links Card ──────────────────────────────────────── async function loadLinks() { try { const resp = await fetch('/api/links'); const links = await resp.json(); const list = document.getElementById('linksList'); if (!links.length) { list.innerHTML = '
  • Keine Links konfiguriert
  • '; return; } list.innerHTML = links.map(l => `
  • ${escapeHtml(l.label)}
  • ` ).join(''); } catch (e) { console.error('Links load failed:', e); } } // 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(); } }); // Node Detail Modal let nodeModalMap = null; let nodeModalMarker = null; let nodeModalTileLayer = 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); const _theme = localStorage.getItem('theme') || 'dark'; nodeModalTileLayer = (_theme === 'dark' ? L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap © CARTO', maxZoom: 19 }) : L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }) ).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(); // ── Dark map tiles – modal (Prio 4) ────────────────── document.addEventListener('themechange', (e) => { if (nodeModalMap && nodeModalTileLayer) { nodeModalMap.removeLayer(nodeModalTileLayer); nodeModalTileLayer = (e.detail.theme === 'dark' ? L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap © CARTO', maxZoom: 19 }) : L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }) ).addTo(nodeModalMap); } }); // ── Node search / sort (Prio 6) ─────────────────────── document.getElementById('nodeSearch').addEventListener('input', (e) => { nodeSearch = e.target.value.trim(); renderNodes(); }); document.getElementById('btnOnlineOnly').addEventListener('click', function () { nodeOnlineFilter = !nodeOnlineFilter; this.classList.toggle('btn-outline-secondary', !nodeOnlineFilter); this.classList.toggle('btn-secondary', nodeOnlineFilter); renderNodes(); }); const _thead = nodesTable.closest('table').querySelector('thead'); _thead.addEventListener('click', (e) => { const th = e.target.closest('th[data-sort]'); if (!th) return; const key = th.dataset.sort; if (nodeSortKey === key) { nodeSortDir = -nodeSortDir; } else { nodeSortKey = key; nodeSortDir = (key === 'last_seen' || key === 'snr' || key === 'battery') ? -1 : 1; } _thead.querySelectorAll('th[data-sort] i').forEach(i => { i.className = 'bi bi-arrow-down-up text-body-secondary'; i.style.fontSize = '.7rem'; }); const icon = th.querySelector('i'); if (icon) { icon.className = `bi ${nodeSortDir < 0 ? 'bi-sort-down' : 'bi-sort-up'} text-info`; icon.style.fontSize = '.7rem'; } renderNodes(); }); // ── Charts ──────────────────────────────────────────── const HOP_COLORS = ['#2196F3', '#4CAF50', '#FF9800', '#F44336', '#9C27B0', '#795548']; const CHART_COLORS = ['#0dcaf0', '#198754', '#ffc107', '#dc3545', '#6610f2', '#0d6efd', '#20c997', '#fd7e14']; const PORTNUM_LABELS = { TEXT_MESSAGE_APP: 'Text', POSITION_APP: 'Position', NODEINFO_APP: 'NodeInfo', TELEMETRY_APP: 'Telemetry', ROUTING_APP: 'Routing', ADMIN_APP: 'Admin', TRACEROUTE_APP: 'Traceroute', NEIGHBORINFO_APP: 'Neighbor', RANGE_TEST_APP: 'RangeTest', }; const PORTNUM_COLORS = { TEXT_MESSAGE_APP: '#0dcaf0', POSITION_APP: '#198754', NODEINFO_APP: '#0d6efd', TELEMETRY_APP: '#ffc107', ROUTING_APP: '#6c757d', ADMIN_APP: '#dc3545', TRACEROUTE_APP: '#6f42c1', NEIGHBORINFO_APP: '#20c997', RANGE_TEST_APP: '#fd7e14', }; function _chartThemeDefaults() { const dark = document.documentElement.getAttribute('data-bs-theme') === 'dark'; return { color: dark ? '#adb5bd' : '#495057', borderColor: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)' }; } function initCharts() { if (!document.getElementById('chartChannel')) return; const d = _chartThemeDefaults(); Chart.defaults.color = d.color; Chart.defaults.borderColor = d.borderColor; chartChannel = new Chart(document.getElementById('chartChannel'), { type: 'doughnut', data: { labels: [], datasets: [{ data: [], backgroundColor: CHART_COLORS, borderWidth: 1 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { boxWidth: 10, font: { size: 10 } } } } } }); chartHops = new Chart(document.getElementById('chartHops'), { type: 'bar', data: { labels: [], datasets: [{ data: [], backgroundColor: HOP_COLORS, borderWidth: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } } }, x: { ticks: { font: { size: 10 } } } } } }); chartHardware = new Chart(document.getElementById('chartHardware'), { type: 'bar', data: { labels: [], datasets: [{ data: [], backgroundColor: '#0dcaf0', borderWidth: 0 }] }, options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } } }, y: { ticks: { font: { size: 10 } } } } } }); chartPacketTypes = new Chart(document.getElementById('chartPacketTypes'), { type: 'doughnut', data: { labels: [], datasets: [{ data: [], backgroundColor: [], borderWidth: 1 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { boxWidth: 10, font: { size: 10 } } } } } }); } function updateChannelChart(stats) { if (!chartChannel) return; const chCounts = stats.channel_breakdown || {}; const entries = Object.entries(channels).sort((a, b) => parseInt(a[0]) - parseInt(b[0])); if (!entries.length) return; chartChannel.data.labels = entries.map(([, name]) => name); chartChannel.data.datasets[0].data = entries.map(([idx]) => chCounts[idx] || 0); chartChannel.update('none'); } function updatePacketTypeChart(stats) { if (!chartPacketTypes) return; const breakdown = stats.packet_type_breakdown || {}; const entries = Object.entries(breakdown).sort((a, b) => b[1] - a[1]); chartPacketTypes.data.labels = entries.map(([t]) => PORTNUM_LABELS[t] || (t ? t.replace(/_APP$/, '') : '?')); chartPacketTypes.data.datasets[0].data = entries.map(([, cnt]) => cnt); chartPacketTypes.data.datasets[0].backgroundColor = entries.map(([t]) => PORTNUM_COLORS[t] || '#adb5bd'); chartPacketTypes.update('none'); } function updateNodeCharts() { if (!chartHops || !chartHardware) return; const nodeList = Object.values(nodes); const hopCounts = {}; nodeList.forEach(n => { const h = n.hop_count != null ? String(n.hop_count) : '?'; hopCounts[h] = (hopCounts[h] || 0) + 1; }); const hopKeys = Object.keys(hopCounts).sort((a, b) => (a === '?' ? 99 : +a) - (b === '?' ? 99 : +b)); chartHops.data.labels = hopKeys.map(h => h === '0' ? 'Direkt' : h === '?' ? '?' : `${h} Hop${+h > 1 ? 's' : ''}`); chartHops.data.datasets[0].data = hopKeys.map(h => hopCounts[h]); chartHops.data.datasets[0].backgroundColor = hopKeys.map(h => HOP_COLORS[+h] || '#9E9E9E'); chartHops.update('none'); const hwCounts = {}; nodeList.forEach(n => { if (n.hw_model) hwCounts[n.hw_model] = (hwCounts[n.hw_model] || 0) + 1; }); const top5 = Object.entries(hwCounts).sort((a, b) => b[1] - a[1]).slice(0, 5); chartHardware.data.labels = top5.map(([hw]) => hw); chartHardware.data.datasets[0].data = top5.map(([, cnt]) => cnt); chartHardware.update('none'); } document.addEventListener('themechange', () => { if (!chartChannel) return; const d = _chartThemeDefaults(); Chart.defaults.color = d.color; Chart.defaults.borderColor = d.borderColor; [chartChannel, chartHops, chartHardware, chartPacketTypes].forEach(c => c && c.update()); }); initCharts(); loadLinks();