- 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>
523 lines
20 KiB
JavaScript
523 lines
20 KiB
JavaScript
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: '© 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();
|