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 {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--red);
background: var(--bs-danger);
}
.status-dot.connected {
background: var(--green);
box-shadow: 0 0 6px var(--green);
background: var(--bs-success);
box-shadow: 0 0 6px var(--bs-success);
}
.nav-links {
display: flex;
gap: 1rem;
}
.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-bar {
display: inline-flex;
align-items: center;
gap: 6px;
}
.battery-indicator {
display: inline-block;
width: 24px;
height: 12px;
border: 1px solid var(--text-secondary);
width: 28px;
height: 13px;
border: 1.5px solid var(--bs-secondary);
border-radius: 2px;
position: relative;
display: inline-block;
vertical-align: middle;
}
.battery-indicator::after {
content: '';
position: absolute;
right: -4px;
right: -5px;
top: 2px;
width: 3px;
height: 6px;
background: var(--text-secondary);
border-radius: 0 1px 1px 0;
height: 7px;
background: var(--bs-secondary);
border-radius: 0 2px 2px 0;
}
.battery-fill {
@ -213,15 +43,15 @@ table tr:hover {
border-radius: 1px;
}
::-webkit-scrollbar {
.table-responsive::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
.table-responsive::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
.table-responsive::-webkit-scrollbar-thumb {
background: var(--bs-border-color);
border-radius: 3px;
}

View file

@ -1,71 +1,113 @@
<!DOCTYPE html>
<html lang="de">
<html lang="de" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
<body>
<header>
<h1>MeshDD-Bot</h1>
<div class="nav-links">
<a href="/map" target="_blank">Karte</a>
<!-- Navbar -->
<nav class="navbar navbar-expand-sm bg-body-tertiary border-bottom">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<i class="bi bi-broadcast-pin text-info me-2"></i>MeshDD-Bot
</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 id="statusText">Verbinde...</span>
</span>
</div>
</div>
<div class="status">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Verbinde...</span>
</div>
</header>
</nav>
<div class="container">
<div class="stats-grid">
<div class="stat-card">
<div class="value" id="statNodes">0</div>
<div class="label">Nodes</div>
<div class="container-fluid py-3">
<!-- Stats Cards -->
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<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>
<div class="stat-card">
<div class="value" id="statPositions">0</div>
<div class="label">Mit Position</div>
<div class="col-6 col-md-3">
<div class="card text-center border-success border-opacity-25">
<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="stat-card">
<div class="value" id="statMessages">0</div>
<div class="label">Nachrichten</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="stat-card">
<div class="value" id="statTextMessages">0</div>
<div class="label">Textnachrichten</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 class="panels">
<div class="panel">
<div class="panel-header">Nodes</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>Name</th>
<th>ID</th>
<th>SNR</th>
<th>Batterie</th>
<th>Zuletzt gesehen</th>
</tr>
</thead>
<tbody id="nodesTable"></tbody>
</table>
<!-- Panels -->
<div class="row g-3">
<!-- Nodes Table -->
<div class="col-lg-7">
<div class="card">
<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>
<th>Name</th>
<th>ID</th>
<th>Hardware</th>
<th class="text-center">SNR</th>
<th class="text-center">Batterie</th>
<th class="text-end">Zuletzt gesehen</th>
</tr>
</thead>
<tbody id="nodesTable"></tbody>
</table>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">Nachrichten</div>
<div class="panel-body">
<ul class="message-list" id="messagesList"></ul>
<!-- Messages -->
<div class="col-lg-5">
<div class="card">
<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>
<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>
</body>
</html>

View file

@ -2,6 +2,7 @@ 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 ws;
@ -48,38 +49,49 @@ function connectWebSocket() {
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 => {
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 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>
<td>${escapeHtml(name)}</td>
<td>${escapeHtml(shortId)}</td>
<td>${snr}</td>
<td>${battery}</td>
<td>${lastSeen}</td>
<td class="${onlineClass}">${escapeHtml(name)}</td>
<td><code>${escapeHtml(shortId)}</code></td>
<td class="text-body-secondary">${escapeHtml(hw)}</td>
<td class="text-center">${snr}</td>
<td class="text-center">${battery}</td>
<td class="text-end text-body-secondary">${lastSeen}</td>
</tr>`;
}).join('');
}
function renderBattery(level) {
if (level == null) return '-';
let color = '#00e676';
if (level < 20) color = '#ff5252';
else if (level < 50) color = '#ff9100';
return `<div class="battery-indicator"><div class="battery-fill" style="width:${level}%;background:${color}"></div></div> ${level}%`;
if (level == null) return '<span class="text-body-secondary">-</span>';
let colorClass = 'bg-success';
if (level < 20) colorClass = 'bg-danger';
else if (level < 50) colorClass = 'bg-warning';
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) {
const li = document.createElement('li');
li.className = 'message-item';
const item = document.createElement('div');
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 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>`;
messagesList.prepend(li);
// Keep max 100 messages
item.innerHTML = `
<div class="d-flex justify-content-between">
<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) {
messagesList.removeChild(messagesList.lastChild);
}
@ -92,6 +104,11 @@ function updateStats(stats) {
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) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return `${seconds}s`;