MeshDD-Bot/static/js/dashboard.js
ppfeiffer 0fd401a395 feat: v0.6.14 - Pakettypen-Diagramm im Dashboard
Viertes Chart-Panel zeigt Pakettyp-Verteilung der letzten 24h als
Doughnut-Diagramm, farblich nach portnum kodiert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 18:02:15 +01:00

559 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
let nodeSearch = '';
let nodeOnlineFilter = false;
let nodeSortKey = 'last_seen';
let nodeSortDir = -1;
let msgChannelFilter = 'all';
let chartChannel = null;
let chartHops = null;
let chartHardware = null;
let chartPacketTypes = null;
initPage({ onAuth: (user) => {
currentUser = user;
updateVisibility();
} });
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));
applyMsgFilter();
break;
case 'channels':
channels = msg.data;
populateChannelDropdown();
renderMsgFilterBar();
break;
case 'my_node_id':
myNodeId = msg.data;
break;
case 'new_message':
addMessage(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 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 `<tr data-node-id="${escapeHtml(node.node_id)}" style="cursor:pointer">
<td class="${onlineClass}">${escapeHtml(name)}</td>
<td class="text-body-secondary">${escapeHtml(hw)}</td>
<td class="text-end px-1">${snr}</td>
<td class="px-1">${battery}</td>
<td class="text-center px-1">${hops}</td>
<td class="text-end px-1 text-body-secondary">${lastSeen}</td>
</tr>`;
}).join('');
updateNodeCharts();
}
function renderBattery(level) {
if (level == null) return '<span class="text-body-secondary">-</span>';
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 `<span class="${colorClass}"><i class="bi ${iconClass} me-1"></i>${level}%</span>`;
}
function addMessage(msg) {
const item = document.createElement('div');
const isSent = myNodeId && msg.from_node === myNodeId;
const chIdx = msg.channel != null ? msg.channel : '?';
item.className = 'msg-item' + (isSent ? ' msg-sent' : '');
item.dataset.channel = String(chIdx);
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 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 = `
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="fw-medium"><i class="bi ${icon} me-1 text-body-secondary"></i>${escapeHtml(from)}</small>
<small class="text-body-secondary"><span class="badge rounded-pill bg-secondary text-white me-1">${escapeHtml(chName)}</span>${time}</small>
</div>
<div class="${bubbleClass}">${escapeHtml(msg.payload || '')}</div>`;
if (msgChannelFilter !== 'all' && String(chIdx) !== msgChannelFilter) {
item.classList.add('d-none');
}
messagesList.prepend(item);
while (messagesList.children.length > 100) {
messagesList.removeChild(messagesList.lastChild);
}
}
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) {
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);
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 `<span class="badge bg-info bg-opacity-75">${escapeHtml(chName)} <span class="badge bg-light text-dark ms-1">${count}</span></span>`;
}).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`;
}
// ── Message channel filter (Prio 7) ──────────────────
function renderMsgFilterBar() {
const bar = document.getElementById('msgFilterBar');
if (!bar) return;
const sorted = Object.entries(channels).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
const mkBtn = (ch, label) => {
const active = ch === msgChannelFilter;
return `<button class="btn btn-sm ${active ? 'btn-secondary' : 'btn-outline-secondary'} py-0 px-1" data-ch="${escapeHtml(ch)}" style="font-size:.7rem">${escapeHtml(label)}</button>`;
};
bar.innerHTML = mkBtn('all', 'Alle') + sorted.map(([idx, name]) => mkBtn(String(idx), name)).join('');
bar.onclick = (e) => {
const btn = e.target.closest('button[data-ch]');
if (!btn) return;
msgChannelFilter = btn.dataset.ch;
bar.querySelectorAll('button[data-ch]').forEach(b => {
b.classList.toggle('btn-secondary', b.dataset.ch === msgChannelFilter);
b.classList.toggle('btn-outline-secondary', b.dataset.ch !== msgChannelFilter);
});
applyMsgFilter();
};
}
function applyMsgFilter() {
messagesList.querySelectorAll('.msg-item[data-channel]').forEach(item => {
const visible = msgChannelFilter === 'all' || item.dataset.channel === msgChannelFilter;
item.classList.toggle('d-none', !visible);
});
}
// 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]) =>
`<tr><td class="text-body-secondary py-0 pe-2" style="white-space:nowrap">${label}</td><td class="py-0">${val}</td></tr>`
).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: '&copy; OpenStreetMap &copy; CARTO', maxZoom: 19 })
: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '&copy; 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: '&copy; OpenStreetMap &copy; CARTO', maxZoom: 19 })
: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '&copy; 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();