MeshDD-Bot/static/js/dashboard.js
ppfeiffer a5ab4550f2 feat(messages/dashboard): Nachrichten öffentlich, Links-Card, Trenner, Badge-Fix
- Nachrichten-Seite /messages ohne Login zugänglich (closes #11)
- new_message/initial_messages an alle WS-Clients (broadcast statt broadcast_auth)
- Dashboard: Nachrichten-Card entfernt, Links-Card (config.yaml) eingefügt
- GET /api/links gibt konfigurierte Links aus config.yaml zurück
- Nachrichten-Trenner: var(--bs-border-color) statt translucent
- msgCount-Badge: bg-secondary-subtle/text-secondary-emphasis (theme-aware)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 14:41:17 +01:00

523 lines
20 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 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 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
? '<i class="bi bi-geo-alt-fill text-success" style="font-size:.75rem"></i>'
: '<i class="bi bi-geo-alt text-body-secondary" style="font-size:.75rem;opacity:.35"></i>';
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="text-end px-1 text-body-secondary">${rssi}</td>
<td class="px-1">${battery}</td>
<td class="text-center px-1">${hops}</td>
<td class="text-center px-1">${posIcon}</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 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.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`;
}
// ── 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 = '<li class="list-group-item text-body-secondary py-2 px-3" style="font-size:.85rem">Keine Links konfiguriert</li>';
return;
}
list.innerHTML = links.map(l =>
`<li class="list-group-item py-2 px-3">
<a href="${escapeHtml(l.url)}" target="_blank" rel="noopener" class="text-decoration-none" style="font-size:.85rem">
<i class="bi bi-box-arrow-up-right me-1 text-body-secondary" style="font-size:.7rem"></i>${escapeHtml(l.label)}
</a>
</li>`
).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]) =>
`<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();
loadLinks();