MeshDD-Bot/static/js/dashboard.js
ppfeiffer 65703b6389 feat: v0.3.5 - AdminLTE-style layout, fix channel names in messages
Redesign dashboard and scheduler with AdminLTE-inspired layout: fixed sidebar
navigation, top navbar, info-boxes, card-outline styling, table-striped.
Fix channel names missing on initial load by sending channels before messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:54:12 +01:00

223 lines
8.2 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 nodes = {};
let channels = {};
let ws;
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 '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>
<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');
item.className = 'msg-item';
const time = msg.timestamp ? new Date(msg.timestamp * 1000).toLocaleTimeString('de-DE') : '';
const fromNode = nodes[msg.from_node];
const from = (fromNode && fromNode.long_name) ? fromNode.long_name : (msg.from_node || '?');
const chIdx = msg.channel != null ? msg.channel : '?';
const chName = channels[chIdx] || `Ch ${chIdx}`;
item.innerHTML = `
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="fw-medium"><i class="bi bi-person-fill me-1 text-body-secondary"></i>${escapeHtml(from)}</small>
<small class="text-body-secondary"><span class="badge badge-pill bg-body-secondary me-1">${escapeHtml(chName)}</span>${time}</small>
</div>
<div class="msg-bubble">${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 breakdown = document.getElementById('commandBreakdown');
const cmds = stats.command_breakdown || {};
if (Object.keys(cmds).length > 0) {
breakdown.innerHTML = Object.entries(cmds).map(([cmd, count]) =>
`<span class="badge bg-primary bg-opacity-75">${escapeHtml(cmd)} <span class="badge bg-light text-dark ms-1">${count}</span></span>`
).join('');
} else {
breakdown.innerHTML = '<span class="text-body-secondary small">Noch keine Anfragen</span>';
}
}
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'));
}
connectWebSocket();