346 lines
13 KiB
JavaScript
346 lines
13 KiB
JavaScript
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;
|
|
|
|
// Auth check
|
|
fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => {
|
|
currentUser = u;
|
|
updateNavbar();
|
|
updateSidebar();
|
|
updateVisibility();
|
|
});
|
|
|
|
function updateNavbar() {
|
|
if (currentUser) {
|
|
document.getElementById('userName').textContent = currentUser.name;
|
|
document.getElementById('userMenu').classList.remove('d-none');
|
|
document.getElementById('loginBtn').classList.add('d-none');
|
|
} else {
|
|
document.getElementById('userMenu').classList.add('d-none');
|
|
document.getElementById('loginBtn').classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
function updateSidebar() {
|
|
const isAdmin = currentUser && currentUser.role === 'admin';
|
|
document.querySelectorAll('.sidebar-admin').forEach(el => {
|
|
el.style.display = isAdmin ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
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));
|
|
break;
|
|
case 'channels':
|
|
channels = msg.data;
|
|
populateChannelDropdown();
|
|
break;
|
|
case 'my_node_id':
|
|
myNodeId = msg.data;
|
|
break;
|
|
case 'new_message':
|
|
addMessage(msg.data);
|
|
break;
|
|
case 'stats_update':
|
|
updateStats(msg.data);
|
|
break;
|
|
}
|
|
};
|
|
}
|
|
|
|
function renderNodes() {
|
|
const sorted = Object.values(nodes).sort((a, b) => (b.last_seen || 0) - (a.last_seen || 0));
|
|
nodeCountBadge.textContent = sorted.length;
|
|
nodesTable.innerHTML = sorted.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('');
|
|
}
|
|
|
|
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;
|
|
item.className = 'msg-item' + (isSent ? ' msg-sent' : '');
|
|
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 chIdx = msg.channel != null ? msg.channel : '?';
|
|
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 badge-pill bg-secondary text-white me-1">${escapeHtml(chName)}</span>${time}</small>
|
|
</div>
|
|
<div class="${bubbleClass}">${escapeHtml(msg.payload || '')}</div>`;
|
|
messagesList.prepend(item);
|
|
while (messagesList.children.length > 100) {
|
|
messagesList.removeChild(messagesList.lastChild);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
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`;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// 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(); }
|
|
});
|
|
|
|
// Theme toggle
|
|
const themeToggle = document.getElementById('themeToggle');
|
|
const themeIcon = document.getElementById('themeIcon');
|
|
|
|
function applyTheme(theme) {
|
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
|
themeIcon.className = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill';
|
|
localStorage.setItem('theme', theme);
|
|
}
|
|
|
|
// Load saved theme
|
|
applyTheme(localStorage.getItem('theme') || 'dark');
|
|
|
|
themeToggle.addEventListener('click', () => {
|
|
const current = document.documentElement.getAttribute('data-bs-theme');
|
|
applyTheme(current === 'dark' ? 'light' : 'dark');
|
|
});
|
|
|
|
// Sidebar toggle (mobile)
|
|
const sidebarToggle = document.getElementById('sidebarToggle');
|
|
const sidebar = document.getElementById('sidebar');
|
|
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
|
|
|
|
if (sidebarToggle) {
|
|
sidebarToggle.addEventListener('click', () => sidebar.classList.toggle('open'));
|
|
sidebarBackdrop.addEventListener('click', () => sidebar.classList.remove('open'));
|
|
}
|
|
|
|
// Node Detail Modal
|
|
let nodeModalMap = null;
|
|
let nodeModalMarker = 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);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap'
|
|
}).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();
|