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();