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:
parent
6bb04ec828
commit
6187bb4419
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
version: "0.6.9"
|
version: "0.6.10"
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
name: "MeshDD-Bot"
|
name: "MeshDD-Bot"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
274
static/js/packets.js
Normal 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
84
static/packets.html
Normal 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>
|
||||||
Loading…
Reference in a new issue