feat: Redesign dashboard with Bootstrap 5.3 dark theme

Replace custom CSS layout with Bootstrap 5.3, add Bootstrap Icons,
responsive card grid for stats, improved nodes table with hardware
column, and styled message list. Online nodes highlighted in green.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-15 13:02:34 +01:00
parent 15955cf8d7
commit d5ea4eee4a
3 changed files with 142 additions and 253 deletions

View file

@ -1,211 +1,41 @@
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #0f3460;
--text-primary: #e0e0e0;
--text-secondary: #a0a0b0;
--accent: #00d4ff;
--accent-dim: #0088aa;
--green: #00e676;
--orange: #ff9100;
--red: #ff5252;
--border: #2a2a4a;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
font-size: 1.5rem;
color: var(--accent);
}
header .status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.status-dot { .status-dot {
display: inline-block;
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
background: var(--red); background: var(--bs-danger);
} }
.status-dot.connected { .status-dot.connected {
background: var(--green); background: var(--bs-success);
box-shadow: 0 0 6px var(--green); box-shadow: 0 0 6px var(--bs-success);
} }
.nav-links { .battery-bar {
display: flex; display: inline-flex;
gap: 1rem; align-items: center;
} gap: 6px;
.nav-links a {
color: var(--accent);
text-decoration: none;
padding: 0.4rem 0.8rem;
border: 1px solid var(--accent-dim);
border-radius: 4px;
font-size: 0.85rem;
transition: background 0.2s;
}
.nav-links a:hover {
background: var(--accent-dim);
color: #fff;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.2rem;
text-align: center;
}
.stat-card .value {
font-size: 2rem;
font-weight: bold;
color: var(--accent);
}
.stat-card .label {
color: var(--text-secondary);
font-size: 0.85rem;
margin-top: 0.3rem;
}
.panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 900px) {
.panels {
grid-template-columns: 1fr;
}
}
.panel {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.panel-header {
background: var(--bg-card);
padding: 0.8rem 1rem;
font-weight: 600;
border-bottom: 1px solid var(--border);
}
.panel-body {
padding: 0;
max-height: 500px;
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
table th,
table td {
padding: 0.6rem 0.8rem;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
table th {
background: var(--bg-card);
color: var(--text-secondary);
font-weight: 600;
position: sticky;
top: 0;
}
table tr:hover {
background: rgba(0, 212, 255, 0.05);
}
.message-list {
list-style: none;
}
.message-item {
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
.message-item .meta {
color: var(--text-secondary);
font-size: 0.75rem;
margin-bottom: 0.2rem;
}
.message-item .text {
color: var(--text-primary);
} }
.battery-indicator { .battery-indicator {
display: inline-block; width: 28px;
width: 24px; height: 13px;
height: 12px; border: 1.5px solid var(--bs-secondary);
border: 1px solid var(--text-secondary);
border-radius: 2px; border-radius: 2px;
position: relative; position: relative;
display: inline-block;
vertical-align: middle;
} }
.battery-indicator::after { .battery-indicator::after {
content: ''; content: '';
position: absolute; position: absolute;
right: -4px; right: -5px;
top: 2px; top: 2px;
width: 3px; width: 3px;
height: 6px; height: 7px;
background: var(--text-secondary); background: var(--bs-secondary);
border-radius: 0 1px 1px 0; border-radius: 0 2px 2px 0;
} }
.battery-fill { .battery-fill {
@ -213,15 +43,15 @@ table tr:hover {
border-radius: 1px; border-radius: 1px;
} }
::-webkit-scrollbar { .table-responsive::-webkit-scrollbar {
width: 6px; width: 6px;
} }
::-webkit-scrollbar-track { .table-responsive::-webkit-scrollbar-track {
background: var(--bg-secondary); background: transparent;
} }
::-webkit-scrollbar-thumb { .table-responsive::-webkit-scrollbar-thumb {
background: var(--border); background: var(--bs-border-color);
border-radius: 3px; border-radius: 3px;
} }

View file

@ -1,71 +1,113 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de" data-bs-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshDD-Bot Dashboard</title> <title>MeshDD-Bot Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
</head> </head>
<body> <body>
<header> <!-- Navbar -->
<h1>MeshDD-Bot</h1> <nav class="navbar navbar-expand-sm bg-body-tertiary border-bottom">
<div class="nav-links"> <div class="container-fluid">
<a href="/map" target="_blank">Karte</a> <a class="navbar-brand" href="/">
</div> <i class="bi bi-broadcast-pin text-info me-2"></i>MeshDD-Bot
<div class="status"> </a>
<div class="d-flex align-items-center gap-3">
<a href="/map" target="_blank" class="btn btn-outline-info btn-sm">
<i class="bi bi-map me-1"></i>Karte
</a>
<span class="badge d-flex align-items-center gap-2" id="statusBadge">
<span class="status-dot" id="statusDot"></span> <span class="status-dot" id="statusDot"></span>
<span id="statusText">Verbinde...</span> <span id="statusText">Verbinde...</span>
</span>
</div> </div>
</header> </div>
</nav>
<div class="container"> <div class="container-fluid py-3">
<div class="stats-grid"> <!-- Stats Cards -->
<div class="stat-card"> <div class="row g-3 mb-3">
<div class="value" id="statNodes">0</div> <div class="col-6 col-md-3">
<div class="label">Nodes</div> <div class="card text-center border-info border-opacity-25">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-info" id="statNodes">0</div>
<div class="text-body-secondary small">Nodes</div>
</div> </div>
<div class="stat-card">
<div class="value" id="statPositions">0</div>
<div class="label">Mit Position</div>
</div> </div>
<div class="stat-card">
<div class="value" id="statMessages">0</div>
<div class="label">Nachrichten</div>
</div> </div>
<div class="stat-card"> <div class="col-6 col-md-3">
<div class="value" id="statTextMessages">0</div> <div class="card text-center border-success border-opacity-25">
<div class="label">Textnachrichten</div> <div class="card-body py-3">
<div class="fs-2 fw-bold text-success" id="statPositions">0</div>
<div class="text-body-secondary small">Mit Position</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center border-warning border-opacity-25">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-warning" id="statMessages">0</div>
<div class="text-body-secondary small">Nachrichten</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card text-center border-primary border-opacity-25">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-primary" id="statTextMessages">0</div>
<div class="text-body-secondary small">Textnachrichten</div>
</div>
</div>
</div> </div>
</div> </div>
<div class="panels"> <!-- Panels -->
<div class="panel"> <div class="row g-3">
<div class="panel-header">Nodes</div> <!-- Nodes Table -->
<div class="panel-body"> <div class="col-lg-7">
<table> <div class="card">
<thead> <div class="card-header d-flex align-items-center">
<i class="bi bi-router me-2 text-info"></i>
<span class="fw-semibold">Nodes</span>
<span class="badge bg-info ms-auto" id="nodeCountBadge">0</span>
</div>
<div class="card-body p-0 table-responsive" style="max-height: 500px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0 align-middle">
<thead class="table-dark sticky-top">
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>ID</th> <th>ID</th>
<th>SNR</th> <th>Hardware</th>
<th>Batterie</th> <th class="text-center">SNR</th>
<th>Zuletzt gesehen</th> <th class="text-center">Batterie</th>
<th class="text-end">Zuletzt gesehen</th>
</tr> </tr>
</thead> </thead>
<tbody id="nodesTable"></tbody> <tbody id="nodesTable"></tbody>
</table> </table>
</div> </div>
</div> </div>
</div>
<div class="panel"> <!-- Messages -->
<div class="panel-header">Nachrichten</div> <div class="col-lg-5">
<div class="panel-body"> <div class="card">
<ul class="message-list" id="messagesList"></ul> <div class="card-header d-flex align-items-center">
<i class="bi bi-chat-dots me-2 text-warning"></i>
<span class="fw-semibold">Nachrichten</span>
</div>
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<div class="list-group list-group-flush" id="messagesList"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/dashboard.js"></script> <script src="/static/js/dashboard.js"></script>
</body> </body>
</html> </html>

View file

@ -2,6 +2,7 @@ const nodesTable = document.getElementById('nodesTable');
const messagesList = document.getElementById('messagesList'); const messagesList = document.getElementById('messagesList');
const statusDot = document.getElementById('statusDot'); const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText'); const statusText = document.getElementById('statusText');
const nodeCountBadge = document.getElementById('nodeCountBadge');
let nodes = {}; let nodes = {};
let ws; let ws;
@ -48,38 +49,49 @@ function connectWebSocket() {
function renderNodes() { function renderNodes() {
const sorted = Object.values(nodes).sort((a, b) => (b.last_seen || 0) - (a.last_seen || 0)); 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 => { nodesTable.innerHTML = sorted.map(node => {
const name = node.long_name || node.short_name || node.node_id; const name = node.long_name || node.short_name || node.node_id;
const snr = node.snr != null ? node.snr.toFixed(1) : '-'; const shortId = node.node_id ? node.node_id.slice(-4) : '?';
const hw = node.hw_model || '-';
const snr = node.snr != null ? `${node.snr.toFixed(1)} dB` : '-';
const battery = renderBattery(node.battery); const battery = renderBattery(node.battery);
const lastSeen = node.last_seen ? timeAgo(node.last_seen) : '-'; const lastSeen = node.last_seen ? timeAgo(node.last_seen) : '-';
const shortId = node.node_id ? node.node_id.slice(-4) : '?'; const onlineClass = isOnline(node.last_seen) ? 'text-success' : '';
return `<tr> return `<tr>
<td>${escapeHtml(name)}</td> <td class="${onlineClass}">${escapeHtml(name)}</td>
<td>${escapeHtml(shortId)}</td> <td><code>${escapeHtml(shortId)}</code></td>
<td>${snr}</td> <td class="text-body-secondary">${escapeHtml(hw)}</td>
<td>${battery}</td> <td class="text-center">${snr}</td>
<td>${lastSeen}</td> <td class="text-center">${battery}</td>
<td class="text-end text-body-secondary">${lastSeen}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
function renderBattery(level) { function renderBattery(level) {
if (level == null) return '-'; if (level == null) return '<span class="text-body-secondary">-</span>';
let color = '#00e676'; let colorClass = 'bg-success';
if (level < 20) color = '#ff5252'; if (level < 20) colorClass = 'bg-danger';
else if (level < 50) color = '#ff9100'; else if (level < 50) colorClass = 'bg-warning';
return `<div class="battery-indicator"><div class="battery-fill" style="width:${level}%;background:${color}"></div></div> ${level}%`; return `<span class="battery-bar">
<span class="battery-indicator"><span class="battery-fill ${colorClass}" style="width:${Math.min(level, 100)}%"></span></span>
<small>${level}%</small>
</span>`;
} }
function addMessage(msg) { function addMessage(msg) {
const li = document.createElement('li'); const item = document.createElement('div');
li.className = 'message-item'; item.className = 'list-group-item list-group-item-action py-2 px-3';
const time = msg.timestamp ? new Date(msg.timestamp * 1000).toLocaleTimeString('de-DE') : ''; const time = msg.timestamp ? new Date(msg.timestamp * 1000).toLocaleTimeString('de-DE') : '';
const from = msg.from_node ? msg.from_node.slice(-4) : '?'; const from = msg.from_node ? msg.from_node.slice(-4) : '?';
li.innerHTML = `<div class="meta">${time} von ${escapeHtml(from)}</div><div class="text">${escapeHtml(msg.payload || '')}</div>`; item.innerHTML = `
messagesList.prepend(li); <div class="d-flex justify-content-between">
// Keep max 100 messages <small class="text-body-secondary"><i class="bi bi-person-fill me-1"></i>${escapeHtml(from)}</small>
<small class="text-body-secondary">${time}</small>
</div>
<div class="mt-1">${escapeHtml(msg.payload || '')}</div>`;
messagesList.prepend(item);
while (messagesList.children.length > 100) { while (messagesList.children.length > 100) {
messagesList.removeChild(messagesList.lastChild); messagesList.removeChild(messagesList.lastChild);
} }
@ -92,6 +104,11 @@ function updateStats(stats) {
document.getElementById('statTextMessages').textContent = stats.text_messages || 0; document.getElementById('statTextMessages').textContent = stats.text_messages || 0;
} }
function isOnline(lastSeen) {
if (!lastSeen) return false;
return (Date.now() / 1000 - lastSeen) < 900; // < 15 min
}
function timeAgo(timestamp) { function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp); const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return `${seconds}s`; if (seconds < 60) return `${seconds}s`;