feat: v0.6.10 - Paket-Log-Seite und größeres Node-Modal

- Neue öffentliche Seite /packets: Echtzeit-Tabelle aller empfangenen
  Meshtastic-Pakete via WebSocket mit Typ-Filter, Pause und Clear
- DB: packets-Tabelle + insert_packet / get_recent_packets
- bot.py: alle Pakete loggen + WS-Broadcast (public)
- webserver.py: /packets Route + /api/packets + initial_packets im WS
- Sidebar: Eintrag 'Pakete' (öffentlich)
- Node-Modal: modal-xl + scrollable, Kartenhöhe 250→300px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-18 17:17:14 +01:00
parent 6bb04ec828
commit 6187bb4419
9 changed files with 460 additions and 8 deletions

View file

@ -1,5 +1,21 @@
# Changelog # Changelog
## [0.6.10] - 2026-02-18
### Added
- **Paket-Log** (`/packets`): neue öffentliche Seite zeigt alle empfangenen Meshtastic-Pakete
in Echtzeit via WebSocket mit Tabelle (Zeit, Von, An, Typ, Kanal, SNR, RSSI, Hops, Info),
Typ-Filterleiste, Pause- und Löschen-Funktion, max. 300 Einträge im Browser.
- **DB**: `packets`-Tabelle + `insert_packet()` / `get_recent_packets()` in `database.py`.
- **bot.py**: `_handle_packet()` loggt alle empfangenen Pakete mit Payload-Zusammenfassung
(Text, Position, Telemetrie, NodeInfo) und broadcasted sie über WebSocket an alle Clients.
- **webserver.py**: Route `/packets` + API `/api/packets` + `initial_packets` im WS-Initial-Payload.
- **Sidebar**: Eintrag „Pakete" (öffentlich) zwischen Karte und Einstellungen.
### Changed
- **Node-Modal**: von `modal-lg` auf `modal-xl` + `modal-dialog-scrollable` vergrößert,
Karten-Höhe von 250 px auf 300 px erhöht.
## [0.6.9] - 2026-02-18 ## [0.6.9] - 2026-02-18
### Fixed ### Fixed

View file

@ -1,4 +1,4 @@
version: "0.6.9" version: "0.6.10"
bot: bot:
name: "MeshDD-Bot" name: "MeshDD-Bot"

View file

@ -241,8 +241,36 @@ class MeshBot:
try: try:
from_id = packet.get("fromId", str(packet.get("from", ""))) from_id = packet.get("fromId", str(packet.get("from", "")))
to_id = packet.get("toId", str(packet.get("to", ""))) to_id = packet.get("toId", str(packet.get("to", "")))
portnum = packet.get("decoded", {}).get("portnum", "") decoded = packet.get("decoded", {})
portnum = decoded.get("portnum", "")
channel = packet.get("channel", 0) channel = packet.get("channel", 0)
snr = packet.get("snr") or packet.get("rxSnr")
rssi = packet.get("rssi") or packet.get("rxRssi")
hop_limit = packet.get("hopLimit")
hop_start = packet.get("hopStart")
packet_id = packet.get("id")
# Build payload summary
payload_summary: dict = {}
if portnum == "TEXT_MESSAGE_APP":
payload_summary = {"text": decoded.get("text", "")}
elif portnum == "POSITION_APP":
pos = decoded.get("position", {})
payload_summary = {"lat": pos.get("latitude"), "lon": pos.get("longitude")}
elif portnum == "TELEMETRY_APP":
dm = decoded.get("telemetry", {}).get("deviceMetrics", {})
payload_summary = {"battery": dm.get("batteryLevel"), "voltage": dm.get("voltage")}
elif portnum == "NODEINFO_APP":
u = decoded.get("user", {})
payload_summary = {"long_name": u.get("longName"), "short_name": u.get("shortName")}
pkt_record = await self.db.insert_packet(
str(from_id), str(to_id), portnum, channel,
snr, rssi, hop_limit, hop_start, packet_id,
json.dumps(payload_summary),
)
if self.ws_manager:
await self.ws_manager.broadcast("packet", pkt_record)
# Update node info from packet # Update node info from packet
node_data = {"snr": packet.get("snr"), "rssi": packet.get("rssi"), node_data = {"snr": packet.get("snr"), "rssi": packet.get("rssi"),
@ -252,7 +280,7 @@ class MeshBot:
# Handle nodeinfo # Handle nodeinfo
if portnum == "NODEINFO_APP": if portnum == "NODEINFO_APP":
user = packet.get("decoded", {}).get("user", {}) user = decoded.get("user", {})
if user and from_id: if user and from_id:
await self.db.upsert_node(str(from_id), await self.db.upsert_node(str(from_id),
long_name=user.get("longName"), long_name=user.get("longName"),
@ -264,7 +292,7 @@ class MeshBot:
# Handle position updates # Handle position updates
if portnum == "POSITION_APP": if portnum == "POSITION_APP":
pos = packet.get("decoded", {}).get("position", {}) pos = decoded.get("position", {})
if pos and from_id: if pos and from_id:
await self.db.upsert_node(str(from_id), await self.db.upsert_node(str(from_id),
lat=pos.get("latitude"), lat=pos.get("latitude"),
@ -276,7 +304,7 @@ class MeshBot:
# Handle telemetry # Handle telemetry
if portnum == "TELEMETRY_APP": if portnum == "TELEMETRY_APP":
telemetry = packet.get("decoded", {}).get("telemetry", {}) telemetry = decoded.get("telemetry", {})
metrics = telemetry.get("deviceMetrics", {}) metrics = telemetry.get("deviceMetrics", {})
if metrics and from_id: if metrics and from_id:
await self.db.upsert_node(str(from_id), await self.db.upsert_node(str(from_id),
@ -288,7 +316,7 @@ class MeshBot:
# Handle text messages # Handle text messages
if portnum == "TEXT_MESSAGE_APP": if portnum == "TEXT_MESSAGE_APP":
text = packet.get("decoded", {}).get("text", "") text = decoded.get("text", "")
my_id = self.get_my_node_id() my_id = self.get_my_node_id()
is_own = my_id and str(from_id) == my_id is_own = my_id and str(from_id) == my_id

View file

@ -83,6 +83,21 @@ class Database:
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS packets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp REAL,
from_id TEXT,
to_id TEXT,
portnum TEXT,
channel INTEGER,
snr REAL,
rssi INTEGER,
hop_limit INTEGER,
hop_start INTEGER,
packet_id INTEGER,
payload TEXT
);
CREATE TABLE IF NOT EXISTS email_logs ( CREATE TABLE IF NOT EXISTS email_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient TEXT NOT NULL, recipient TEXT NOT NULL,
@ -288,3 +303,24 @@ class Database:
(recipient, subject, status, error_message, now), (recipient, subject, status, error_message, now),
) )
await self.db.commit() await self.db.commit()
# ── Packet log methods ────────────────────────────
async def insert_packet(self, from_id: str, to_id: str, portnum: str, channel: int,
snr, rssi, hop_limit, hop_start, packet_id, payload: str) -> dict:
now = time.time()
cursor = await self.db.execute(
"INSERT INTO packets (timestamp, from_id, to_id, portnum, channel, snr, rssi, "
"hop_limit, hop_start, packet_id, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(now, from_id, to_id, portnum, channel, snr, rssi, hop_limit, hop_start, packet_id, payload),
)
await self.db.commit()
async with self.db.execute("SELECT * FROM packets WHERE id = ?", (cursor.lastrowid,)) as c:
row = await c.fetchone()
return dict(row) if row else {}
async def get_recent_packets(self, limit: int = 200) -> list[dict]:
async with self.db.execute(
"SELECT * FROM packets ORDER BY timestamp DESC LIMIT ?", (limit,)
) as cursor:
return [dict(row) async for row in cursor]

View file

@ -59,6 +59,7 @@ class WebServer:
self.app.router.add_get("/ws", self._ws_handler) self.app.router.add_get("/ws", self._ws_handler)
self.app.router.add_get("/api/nodes", self._api_nodes) self.app.router.add_get("/api/nodes", self._api_nodes)
self.app.router.add_get("/api/messages", self._api_messages) self.app.router.add_get("/api/messages", self._api_messages)
self.app.router.add_get("/api/packets", self._api_packets)
self.app.router.add_get("/api/stats", self._api_stats) self.app.router.add_get("/api/stats", self._api_stats)
self.app.router.add_get("/api/scheduler/jobs", self._api_scheduler_get) self.app.router.add_get("/api/scheduler/jobs", self._api_scheduler_get)
self.app.router.add_post("/api/scheduler/jobs", self._api_scheduler_add) self.app.router.add_post("/api/scheduler/jobs", self._api_scheduler_add)
@ -72,6 +73,7 @@ class WebServer:
self.app.router.add_get("/settings", self._serve_settings) self.app.router.add_get("/settings", self._serve_settings)
self.app.router.add_get("/scheduler", self._serve_scheduler) self.app.router.add_get("/scheduler", self._serve_scheduler)
self.app.router.add_get("/map", self._serve_map) self.app.router.add_get("/map", self._serve_map)
self.app.router.add_get("/packets", self._serve_packets)
self.app.router.add_get("/", self._serve_index) self.app.router.add_get("/", self._serve_index)
self.app.router.add_static("/static", STATIC_DIR) self.app.router.add_static("/static", STATIC_DIR)
@ -107,6 +109,9 @@ class WebServer:
"uptime": self.bot.get_uptime(), "uptime": self.bot.get_uptime(),
}})) }}))
packets = await self.db.get_recent_packets(200)
await ws.send_str(json.dumps({"type": "initial_packets", "data": packets}))
if user: if user:
messages = await self.db.get_recent_messages(50) messages = await self.db.get_recent_messages(50)
await ws.send_str(json.dumps({"type": "initial_messages", "data": messages})) await ws.send_str(json.dumps({"type": "initial_messages", "data": messages}))
@ -129,6 +134,11 @@ class WebServer:
messages = await self.db.get_recent_messages(limit) messages = await self.db.get_recent_messages(limit)
return web.json_response(messages) return web.json_response(messages)
async def _api_packets(self, request: web.Request) -> web.Response:
limit = int(request.query.get("limit", "200"))
packets = await self.db.get_recent_packets(limit)
return web.json_response(packets)
async def _api_stats(self, request: web.Request) -> web.Response: async def _api_stats(self, request: web.Request) -> web.Response:
stats = await self.db.get_stats() stats = await self.db.get_stats()
stats["version"] = config.get("version", "0.0.0") stats["version"] = config.get("version", "0.0.0")
@ -175,6 +185,9 @@ class WebServer:
async def _serve_map(self, request: web.Request) -> web.Response: async def _serve_map(self, request: web.Request) -> web.Response:
return web.FileResponse(os.path.join(STATIC_DIR, "map.html")) return web.FileResponse(os.path.join(STATIC_DIR, "map.html"))
async def _serve_packets(self, request: web.Request) -> web.Response:
return web.FileResponse(os.path.join(STATIC_DIR, "packets.html"))
async def _serve_scheduler(self, request: web.Request) -> web.Response: async def _serve_scheduler(self, request: web.Request) -> web.Response:
return web.FileResponse(os.path.join(STATIC_DIR, "scheduler.html")) return web.FileResponse(os.path.join(STATIC_DIR, "scheduler.html"))

View file

@ -191,7 +191,7 @@
<!-- Node Detail Modal --> <!-- Node Detail Modal -->
<div class="modal fade" id="nodeModal" tabindex="-1"> <div class="modal fade" id="nodeModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header py-2"> <div class="modal-header py-2">
<h6 class="modal-title d-flex align-items-center gap-2"> <h6 class="modal-title d-flex align-items-center gap-2">
@ -208,7 +208,7 @@
</table> </table>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div id="modalMapContainer" style="height:250px;border-radius:.375rem;overflow:hidden"></div> <div id="modalMapContainer" style="height:300px;border-radius:.375rem;overflow:hidden"></div>
<div id="modalNoPosition" class="text-body-secondary text-center py-5 d-none"> <div id="modalNoPosition" class="text-body-secondary text-center py-5 d-none">
<i class="bi bi-geo-alt-fill fs-3 d-block mb-1"></i>Keine Position verfuegbar <i class="bi bi-geo-alt-fill fs-3 d-block mb-1"></i>Keine Position verfuegbar
</div> </div>

View file

@ -7,6 +7,7 @@ const _SIDEBAR_LINKS = [
{ href: '/', icon: 'bi-speedometer2', label: 'Dashboard', admin: false }, { href: '/', icon: 'bi-speedometer2', label: 'Dashboard', admin: false },
{ href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', admin: true }, { href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', admin: true },
{ href: '/map', icon: 'bi-map', label: 'Karte', admin: false }, { href: '/map', icon: 'bi-map', label: 'Karte', admin: false },
{ href: '/packets', icon: 'bi-reception-4', label: 'Pakete', admin: false },
{ href: '/settings', icon: 'bi-gear', label: 'Einstellungen',admin: true }, { href: '/settings', icon: 'bi-gear', label: 'Einstellungen',admin: true },
{ href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true }, { href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true },
]; ];

274
static/js/packets.js Normal file
View file

@ -0,0 +1,274 @@
// MeshDD-Bot Paket-Log
const MAX_ROWS = 300;
let ws = null;
let nodes = {}; // node_id -> {long_name, short_name}
let paused = false;
let activeFilter = 'all';
let pendingRows = []; // rows held while paused
const pktBody = document.getElementById('pktBody');
const pktCount = document.getElementById('pktCount');
const pktFilterBar = document.getElementById('pktFilterBar');
const pktPauseBtn = document.getElementById('pktPauseBtn');
const pktClearBtn = document.getElementById('pktClearBtn');
const tableWrapper = document.getElementById('pktTableWrapper');
// ── Portnum config ─────────────────────────────────────────
const PORTNUM_CFG = {
TEXT_MESSAGE_APP: { label: 'Text', color: 'info' },
POSITION_APP: { label: 'Position', color: 'success' },
NODEINFO_APP: { label: 'NodeInfo', color: 'primary' },
TELEMETRY_APP: { label: 'Telemetry', color: 'warning' },
ROUTING_APP: { label: 'Routing', color: 'secondary'},
ADMIN_APP: { label: 'Admin', color: 'danger' },
TRACEROUTE_APP: { label: 'Traceroute',color: 'purple' },
NEIGHBORINFO_APP: { label: 'Neighbor', color: 'teal' },
RANGE_TEST_APP: { label: 'RangeTest', color: 'orange' },
};
const knownTypes = new Set();
// ── Helpers ────────────────────────────────────────────────
function nodeName(id) {
if (!id) return '—';
const n = nodes[id];
if (n) {
const name = n.short_name || n.long_name;
if (name) return escapeHtml(name);
}
return escapeHtml(id);
}
function nodeTitle(id) {
if (!id) return '';
const n = nodes[id];
if (n && (n.long_name || n.short_name)) {
return `title="${escapeHtml(n.long_name || n.short_name)} (${escapeHtml(id)})"`;
}
return '';
}
function fmtTime(ts) {
if (!ts) return '—';
const d = new Date(ts * 1000);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function fmtTo(toId) {
if (toId === '4294967295' || toId === '^all' || toId === 'ffffffff') {
return '<span class="text-body-secondary">Alle</span>';
}
return `<span ${nodeTitle(toId)}>${nodeName(toId)}</span>`;
}
function portnumBadge(portnum) {
const cfg = PORTNUM_CFG[portnum];
if (cfg) {
return `<span class="badge bg-${cfg.color} bg-opacity-20 text-${cfg.color}" style="font-size:.65rem">${cfg.label}</span>`;
}
const short = portnum ? portnum.replace(/_APP$/, '') : '?';
return `<span class="badge bg-secondary bg-opacity-20 text-secondary" style="font-size:.65rem">${escapeHtml(short)}</span>`;
}
function fmtPayload(portnum, payloadStr) {
let p = {};
try { p = JSON.parse(payloadStr || '{}'); } catch { return ''; }
if (portnum === 'TEXT_MESSAGE_APP' && p.text) {
return `<span class="text-body">${escapeHtml(p.text)}</span>`;
}
if (portnum === 'POSITION_APP' && p.lat != null) {
return `<span class="text-body-secondary">${p.lat?.toFixed(5)}, ${p.lon?.toFixed(5)}</span>`;
}
if (portnum === 'TELEMETRY_APP') {
const parts = [];
if (p.battery != null) parts.push(`🔋 ${p.battery}%`);
if (p.voltage != null) parts.push(`${p.voltage?.toFixed(2)} V`);
return `<span class="text-body-secondary">${parts.join(' · ')}</span>`;
}
if (portnum === 'NODEINFO_APP' && (p.long_name || p.short_name)) {
return `<span class="text-body-secondary">${escapeHtml(p.long_name || '')}${p.short_name ? ` [${escapeHtml(p.short_name)}]` : ''}</span>`;
}
return '';
}
function fmtSnr(v) {
if (v == null) return '<span class="text-body-secondary">—</span>';
const cls = v >= 5 ? 'text-success' : v >= 0 ? 'text-warning' : 'text-danger';
return `<span class="${cls}">${v > 0 ? '+' : ''}${v}</span>`;
}
function fmtRssi(v) {
if (v == null) return '<span class="text-body-secondary">—</span>';
const cls = v >= -100 ? 'text-success' : v >= -115 ? 'text-warning' : 'text-danger';
return `<span class="${cls}">${v}</span>`;
}
function fmtHops(limit, start) {
if (start == null || limit == null) return '<span class="text-body-secondary">—</span>';
const used = start - limit;
return `<span class="text-body-secondary">${used}/${start}</span>`;
}
// ── Filter bar ─────────────────────────────────────────────
function renderFilterBar() {
const types = ['all', ...Array.from(knownTypes).sort()];
pktFilterBar.innerHTML = types.map(t => {
const label = t === 'all' ? 'Alle' : (PORTNUM_CFG[t]?.label || t.replace(/_APP$/, ''));
const active = t === activeFilter;
return `<button class="btn btn-sm ${active ? 'btn-secondary' : 'btn-outline-secondary'} py-0 px-1 pkt-filter-btn"
data-type="${escapeHtml(t)}" style="font-size:.7rem">${escapeHtml(label)}</button>`;
}).join('');
}
pktFilterBar.addEventListener('click', e => {
const btn = e.target.closest('.pkt-filter-btn');
if (!btn) return;
activeFilter = btn.dataset.type;
renderFilterBar();
applyFilter();
});
function applyFilter() {
pktBody.querySelectorAll('tr[data-portnum]').forEach(row => {
const visible = activeFilter === 'all' || row.dataset.portnum === activeFilter;
row.classList.toggle('d-none', !visible);
});
}
// ── Row rendering ──────────────────────────────────────────
function buildRow(pkt) {
const tr = document.createElement('tr');
tr.dataset.portnum = pkt.portnum || '';
if (activeFilter !== 'all' && tr.dataset.portnum !== activeFilter) {
tr.classList.add('d-none');
}
tr.innerHTML =
`<td class="text-body-secondary" style="font-size:.75rem;white-space:nowrap">${fmtTime(pkt.timestamp)}</td>` +
`<td style="font-size:.8rem;white-space:nowrap"><span ${nodeTitle(pkt.from_id)}>${nodeName(pkt.from_id)}</span></td>` +
`<td style="font-size:.8rem;white-space:nowrap">${fmtTo(pkt.to_id)}</td>` +
`<td>${portnumBadge(pkt.portnum)}</td>` +
`<td class="text-body-secondary" style="font-size:.8rem">${pkt.channel ?? '—'}</td>` +
`<td style="font-size:.8rem">${fmtSnr(pkt.snr)}</td>` +
`<td style="font-size:.8rem">${fmtRssi(pkt.rssi)}</td>` +
`<td style="font-size:.8rem">${fmtHops(pkt.hop_limit, pkt.hop_start)}</td>` +
`<td style="font-size:.8rem">${fmtPayload(pkt.portnum, pkt.payload)}</td>`;
return tr;
}
function addRow(pkt, prepend = true) {
if (pkt.portnum) knownTypes.add(pkt.portnum);
const row = buildRow(pkt);
if (prepend) {
pktBody.prepend(row);
// Trim excess rows
while (pktBody.children.length > MAX_ROWS) {
pktBody.removeChild(pktBody.lastChild);
}
} else {
pktBody.appendChild(row);
}
updateCount();
}
function updateCount() {
const total = pktBody.children.length;
pktCount.textContent = `${total} Einträge`;
}
// ── Pause / Clear ──────────────────────────────────────────
pktPauseBtn.addEventListener('click', () => {
paused = !paused;
const icon = pktPauseBtn.querySelector('i');
icon.className = paused ? 'bi bi-play-fill' : 'bi bi-pause-fill';
pktPauseBtn.title = paused ? 'Weiter' : 'Pause';
if (!paused && pendingRows.length) {
pendingRows.forEach(p => addRow(p, true));
pendingRows = [];
renderFilterBar();
}
});
pktClearBtn.addEventListener('click', () => {
pktBody.innerHTML = '';
pendingRows = [];
updateCount();
});
// ── WebSocket ──────────────────────────────────────────────
function connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => {
document.getElementById('statusDot').className = 'status-dot online';
document.getElementById('statusText').textContent = 'Verbunden';
};
ws.onclose = () => {
document.getElementById('statusDot').className = 'status-dot';
document.getElementById('statusText').textContent = 'Getrennt';
setTimeout(connectWs, 3000);
};
ws.onmessage = e => {
const msg = JSON.parse(e.data);
handleMsg(msg);
};
}
function handleMsg(msg) {
switch (msg.type) {
case 'initial':
// Populate nodes map
(msg.data || []).forEach(n => {
if (n.node_id) nodes[n.node_id] = n;
});
break;
case 'node_update':
if (msg.data && msg.data.node_id) nodes[msg.data.node_id] = msg.data;
break;
case 'initial_packets':
// DB returns newest-first (DESC) — append in that order → newest at top
pktBody.innerHTML = '';
(msg.data || []).forEach(p => {
if (p.portnum) knownTypes.add(p.portnum);
pktBody.appendChild(buildRow(p));
});
renderFilterBar();
updateCount();
break;
case 'packet':
if (paused) {
pendingRows.push(msg.data);
} else {
if (msg.data.portnum) knownTypes.add(msg.data.portnum);
addRow(msg.data, true);
renderFilterBar();
}
break;
case 'bot_status':
if (msg.data) {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
if (dot && text) {
dot.className = 'status-dot ' + (msg.data.connected ? 'online' : 'offline');
text.textContent = msg.data.connected ? 'Verbunden' : 'Getrennt';
}
}
break;
}
}
// ── Init ───────────────────────────────────────────────────
initPage();
connectWs();

84
static/packets.html Normal file
View file

@ -0,0 +1,84 @@
<!DOCTYPE html>
<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 Paket-Log</title>
<link href="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.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 class="antialiased">
<!-- Top Navbar -->
<nav class="top-navbar d-flex align-items-center px-3">
<button class="btn btn-link text-body p-0 me-2 d-lg-none" id="sidebarToggle">
<i class="bi bi-list fs-5"></i>
</button>
<span class="fw-bold me-auto">
<i class="bi bi-broadcast-pin text-info me-1"></i>MeshDD-Bot
</span>
<div class="d-flex align-items-center gap-2">
<span class="d-flex align-items-center gap-1">
<span class="status-dot" id="statusDot"></span>
<small class="text-body-secondary" id="statusText">Verbinde...</small>
</span>
<span id="userMenu" class="d-none">
<small class="text-body-secondary me-1"><i class="bi bi-person-fill me-1"></i><span id="userName"></span></small>
<a href="/auth/logout" class="btn btn-sm btn-outline-secondary py-0 px-1" title="Abmelden">
<i class="bi bi-box-arrow-right" style="font-size:.75rem"></i>
</a>
</span>
<a href="/login" id="loginBtn" class="btn btn-sm btn-outline-info py-0 px-1 d-none">
<i class="bi bi-person" style="font-size:.75rem"></i> Login
</a>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
</div>
</nav>
<aside class="sidebar" id="sidebar"></aside>
<div class="sidebar-backdrop" id="sidebarBackdrop"></div>
<main class="content-wrapper">
<div class="card card-outline">
<div class="card-header py-2 d-flex align-items-center gap-2 flex-wrap">
<i class="bi bi-reception-4 me-1"></i>
<span class="fw-semibold">Paket-Log</span>
<small class="text-body-secondary" id="pktCount"></small>
<div class="ms-auto d-flex gap-1 flex-wrap" id="pktFilterBar"></div>
<button class="btn btn-sm btn-outline-secondary py-0 px-2" id="pktPauseBtn" title="Pause">
<i class="bi bi-pause-fill" style="font-size:.75rem"></i>
</button>
<button class="btn btn-sm btn-outline-danger py-0 px-2" id="pktClearBtn" title="Löschen">
<i class="bi bi-trash" style="font-size:.75rem"></i>
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height:calc(100vh - 130px);overflow-y:auto" id="pktTableWrapper">
<table class="table table-sm table-hover mb-0" id="pktTable">
<thead class="sticky-top">
<tr>
<th style="width:80px">Zeit</th>
<th style="width:90px">Von</th>
<th style="width:90px">An</th>
<th style="width:140px">Typ</th>
<th style="width:45px">Ch</th>
<th style="width:55px">SNR</th>
<th style="width:60px">RSSI</th>
<th style="width:45px">Hops</th>
<th>Info</th>
</tr>
</thead>
<tbody id="pktBody"></tbody>
</table>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script>
<script src="/static/js/packets.js"></script>
</body>
</html>